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.
@@ -44,6 +44,8 @@ import { detectOverengineeringText } from "./overengineering.js";
44
44
  // 用户批评形态(规则 22②;2026-09-02 建、2026-09-08 第二批 §1.3 重构为 A″):
45
45
  // A″ = 语义主判、无通用词表、宽松触发、行为闸:
46
46
  // - 通用层只留**语言无关形态**:连续问号/叹号(CRITICISM_SHAPE_RE)+ 英文全大写比率(hasHighCapsRatio);
47
+ // 2026-09-15(踩坑 144 修法 (b)):大写比率一支改由 **hasShoutingWordShape** 把关——只认"词形" token
48
+ // (连续字母 ≥4、≥2 个),编号标签(A1/B5)与缩写(ESR/DSH)不再计为"喊叫";纯比率函数保留导出供配置层复用。
47
49
  // - 辱骂词枚举**迁出**到本机 rule-engine.json 的 criticismPersonal(发布面不含个人话术),经 setCriticismPersonal 注入;
48
50
  // - 机器层只产"疑似"信号(不再直接产 correct),裁决归 LLM 层(judge/deliverSuspects 兜底);
49
51
  // - 行为闸在 index.js:命中疑似 → 冻结本回合写类工具 + 注入四段模板(停止/归因四问/三件套/等指令)。
@@ -66,7 +68,9 @@ function personalCriticismRe() {
66
68
  return new RegExp(`(?:${alts.join("|")})`);
67
69
  }
68
70
 
69
- /** 情绪强度信号:英文全大写比率 ≥ 阈值(默认 0.6,经 patterns.criticism_caps_ratio 可配置)且字母数 ≥6(语言无关的"喊叫"形态) */
71
+ /** 情绪强度信号:英文全大写比率 ≥ 阈值(默认 0.6,经 patterns.criticism_caps_ratio 可配置)且字母数 ≥6
72
+ *(语言无关的"喊叫"形态)。**注意**:本函数只看比率,不区分"词"与"标识符"——判"喊叫"请用
73
+ * hasShoutingWordShape(2026-09-15 起 criticismSignals 用的是后者)。 */
70
74
  export function hasHighCapsRatio(text) {
71
75
  const t = String(text || "");
72
76
  const letters = t.replace(/[^A-Za-z]/g, "");
@@ -75,10 +79,24 @@ export function hasHighCapsRatio(text) {
75
79
  return caps.length / letters.length >= patNum("criticism_caps_ratio");
76
80
  }
77
81
 
82
+ /** 词形大写"喊叫"形态(2026-09-15,踩坑 144 修法 (b) 收紧档):**只认词形 token**——
83
+ * 取「连续字母 ≥ criticism_caps_min_word_len」的 token(`A1`/`B5`/`12B` 这类编号因连续字母不足被排除、
84
+ * 3 字母缩写 `ESR` 亦不足默认 4),需此类 token ≥ criticism_caps_min_tokens 个,
85
+ * 再对这些 token 的整体大写比率套 hasHighCapsRatio。
86
+ * 由来:纯比率版在中文/技术语境必然误报(几个大写标记即 100%),A″ 因此误冻结写类工具。 */
87
+ export function hasShoutingWordShape(text) {
88
+ const t = String(text || "");
89
+ const minLen = Math.max(2, patNum("criticism_caps_min_word_len"));
90
+ const minToks = Math.max(1, patNum("criticism_caps_min_tokens"));
91
+ const toks = t.match(new RegExp(`[A-Za-z]{${minLen},}`, "g")) || [];
92
+ if (toks.length < minToks) return false;
93
+ return hasHighCapsRatio(toks.join(" "));
94
+ }
95
+
78
96
  /** 批评疑似检测(A″):{shape, personal, weak, suspect}——机器只产疑似,判定权归模型 */
79
97
  export function criticismSignals(text) {
80
98
  const t = String(text || "");
81
- const shape = criticismShapeRe().test(t) || hasHighCapsRatio(t);
99
+ const shape = criticismShapeRe().test(t) || hasShoutingWordShape(t);
82
100
  const personal = (personalCriticismRe() || /$^/).test(t);
83
101
  const weak = criticismWeakRe().test(t);
84
102
  return { shape, personal, weak, suspect: shape || personal ? "strong" : weak ? "weak" : null };
@@ -26,6 +26,11 @@ const ANALYSIS_TOOLS = new Set([
26
26
  // 子代理交付工具(官方 dsh-tool-subagent-report,2026-08-24 实测发现):仅子代理环境注册,
27
27
  // 参数仅 output 文本 → 直接交付父代理,无文件副作用 → analysis(避免子代理交付被 unknown deny 阻断)
28
28
  "report",
29
+ // 官方交付物声明工具(2026-09-14 补录:verify-all ⑤「工具箱覆盖」实测 COVERAGE-FAIL —— 官方
30
+ // tool-catalog 全集含 present 而本表未收录,会被 unknownToolDecision 当未知工具首调处置):
31
+ // present 只把**已存在**的文件登记为交付物(纯会话态;不复制内容、不写盘、不改文件)→ analysis。
32
+ // 与相邻两类工具的分界:report/visualize 会写 artifact(visualize 落一份卡片副本),present 不写。
33
+ "present",
29
34
  // 官方子代理模型发现(@deepseek-ai/dsh-tool-subagent,2026-09-06 工具目录漂移补录):只读枚举
30
35
  // 可用子代理模型(模型发现/所选路由校验),无持久副作用 → analysis
31
36
  "list_subagent_models",
@@ -36,7 +41,10 @@ const ANALYSIS_TOOLS = new Set([
36
41
  "cordis_inspect_list", "cordis_inspect_query", "cordis_inspect_self",
37
42
  "ask_user_question",
38
43
  // ESR 状态查询(读工作区记忆状态,无持久副作用)
39
- "esr_status", "esr_ready", "esr_model"
44
+ "esr_status", "esr_ready", "esr_model",
45
+ // univer-office 只读族(2026-09-09 补录:规则 24④ 实测 univer_execute 被硬拦——该族未分类;
46
+ // 逐工具分类而非前缀一刀切:只读/查询类 → analysis)
47
+ "univer_status", "univer_inspect", "univer_lint", "univer_api", "univer_resources"
40
48
  ]);
41
49
 
42
50
  // 产物类:工具自然行为会写文件(artifact/工作区/会话存储),低风险——放行 + 审计。
@@ -59,7 +67,9 @@ const ARTIFACT_TOOLS = new Set([
59
67
  // (12A isSensitiveToolCall / 13A / 24 装配判定)链上覆盖,不再各自归类。
60
68
  "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote",
61
69
  // ESR 工程状态写操作(写工作区记忆存储,engram_store 同族)
62
- "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc"
70
+ "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc",
71
+ // univer-office 产物族(2026-09-09 补录):截图写 artifact;univer_new 只新建不覆盖
72
+ "univer_screenshot", "univer_new"
63
73
  ]);
64
74
 
65
75
  // 变更类:维持现有严格授权(规则 22 粒度比对 / 12A 敏感授权 / 13A 备份)
@@ -77,7 +87,10 @@ const MUTATING_TOOLS = new Set([
77
87
  "followup_task", "spawn_teammate", "team_task_create", "team_task_get",
78
88
  "team_task_list", "team_task_update", "wait_agent",
79
89
  // 本机命名空间兜底(dev_ 前缀 = 开发侧工具,行为可写可管理;具体已枚举,前缀兜底未来新增)
80
- "dev_router_status"
90
+ "dev_router_status",
91
+ // univer-office 变更族(2026-09-09 补录,规则 24④:未分类 + 疑似写 → 硬拦;逐工具分类纳入守卫链)
92
+ "univer_execute", "univer_import", "univer_unit", "univer_worktree",
93
+ "univer_compile_svg", "univer_export", "univer_print_pdf"
81
94
  ]);
82
95
 
83
96
  // 前缀规则:生态命名空间的工具自动归类(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,
@@ -63,7 +64,10 @@ import { toolClass } from "./core/tool-catalog.js";
63
64
  import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
64
65
  import { state } from "./core/runtime.js";
65
66
  import { APPROVE_TYPES, normalizePath, setTypeHints } from "./core/authorization.js";
66
- import { setLexicons } from "./core/lexicon.js";
67
+ import { setLexicons, approvalExecRe } from "./core/lexicon.js";
68
+ // C4(批 3,2026-09-15):质量账本查询入口(/guard quality)。机制自 0.6.3 已在 core/quality-ledger.js,
69
+ // 但 lib/ 内此前零引用(grep "quality" = 0 命中)——机制写了却没人能用,本批补入口。
70
+ import { loadLedger, qualityTrend } from "./core/quality-ledger.js";
67
71
  import { getMessage } from "./messages.js";
68
72
  import { getBlindSpots, getFreedomSurface, setGuardFreedom } from "./core/guard-core.js";
69
73
  import { detectLang, getLang, getLangSource, pickLang } from "./core/lang.js";
@@ -108,11 +112,30 @@ export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skil
108
112
 
109
113
  const pluginConfig = loadPluginConfig();
110
114
  // §1.3 行为闸判定(纯函数,2026-09-08 第二批 A″):批评疑似回合冻结写类工具——返回拒绝原因或 null
111
- export function criticismFreezeDecision(session, toolName, args) {
115
+ // C2-C 档(批 3,2026-09-15):新增 opts.bypassActive —— `/guard bypass` 的语义是「全部守卫暂停」
116
+ //(guard status 文案原文),而本闸此前只查 turn.criticismFrozen、不检查 state.bypassUntil →
117
+ // 用户显式放行后仍被 A″ 冻结,语义不一致。bypassActive 由调用方从 state.bypassUntil 计算后传入,
118
+ // 本函数保持**纯函数**(可单测);窗口内的留痕由 guard 路径既有的 bypass-action 审计统一负责(M2)。
119
+ export function criticismFreezeDecision(session, toolName, args, opts = {}) {
120
+ if (opts.bypassActive) return null;
112
121
  if (!session?.turn?.criticismFrozen) return null;
113
122
  if (toolClass(toolName, args || {}) !== "mutating") return null;
114
123
  return "本回合命中批评/指错疑似信号——写类工具已冻结(A″ 行为闸)。请按四段模板回应:① 停止;② 归因四问;③ 三件套(事实/原因/方案);④ 等指令。只读操作不受限。";
115
124
  }
125
+
126
+ /** C3(批 3,2026-09-15,用户裁定方案 A):**是否冻结**本回合写类工具(纯函数,可单测)。
127
+ * 由来:置位逻辑原对强弱信号**不对称**——弱信号已做反向检查(hasExplicitExecWord,只认动作词),
128
+ * 强信号却无条件冻结 → 「批评 + 明确指令」的混合消息会把用户同时下达的指令一起冻掉,
129
+ * 而那条指令本身是**完整授权**(许可词 + 执行语,规则 22 的授权定义)。
130
+ * 判据:强信号下只有 `approvalExecRe()`(许可词 + [0,16] 字符 + 执行语,缺一不可)才豁免——
131
+ * 比弱信号的反向检查更严,避免「批评里提个动作词就算授权」的过宽。
132
+ * 与 criticismFreezeDecision 分工:本函数管「要不要冻」,那个管「冻了之后拦不拦」。
133
+ * @returns {boolean} true = 冻结本回合写类工具
134
+ */
135
+ export function criticismFreezeBySignal(suspect, userText) {
136
+ if (suspect !== "strong") return false;
137
+ return !approvalExecRe().test(String(userText || ""));
138
+ }
116
139
  // C4:启动时合并配置层 typeHints(本机扩展授权类型;默认空=仅内置 8+archive 类)
117
140
  setTypeHints(pluginConfig.typeHints);
118
141
  // P8 小批 A(2026-09-08):行为词表配置层注入(rule-engine.json 的 lexicons 键)——
@@ -536,10 +559,14 @@ export function handleSessionEvent(ctx, session, event) {
536
559
  reason: getMessage("memory.sediment-missing"),
537
560
  session: sid
538
561
  });
539
- maybeInject(ctx, sid, {
540
- ruleId: "__engram-gap",
541
- reason: getMessage("memory.sediment-missing")
542
- });
562
+ // D(2026-09-13):事中已提示过(落盘那一刻)→ 此处只留审计,不重复投递;
563
+ // 未开启 hintOnWrite 或事中未提示时,此处仍是兜底投递(默认关时行为与改动前一致)。
564
+ if (!s.turn.m8Hinted) {
565
+ maybeInject(ctx, sid, {
566
+ ruleId: "__engram-gap",
567
+ reason: getMessage("memory.sediment-missing")
568
+ });
569
+ }
543
570
  }
544
571
  // F1(2026-08-28 阶段三):规则 2 时序竞态修复——assistant/message 检测到时间词违规时
545
572
  // 不立即投递(Get-Date 工具常在本回合后续步骤才执行,事故实弹:correct 早于 Get-Date 放行),
@@ -710,7 +737,8 @@ export function handleSessionEvent(ctx, session, event) {
710
737
  if (s.turn.toolCount === 1) s.turn.firstToolName = toolName;
711
738
  if (toolName === "ask_user_question") {
712
739
  s.turn.askSeen = true;
713
- s.turn.pendingAsk = { callId: d.callId, questions: args?.questions || [] };
740
+ // D2 修复(2026-09-10):写**会话级**(不随 turn/start 清空);at 供陈旧值保护
741
+ s.pendingAsk = { callId: d.callId, questions: args?.questions || [], at: Date.now() };
714
742
  }
715
743
  const pendingCall = { name: toolName, args };
716
744
  const targetPath = args?.file_path ?? args?.path;
@@ -869,8 +897,34 @@ export function handleSessionEvent(ctx, session, event) {
869
897
  const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
870
898
  const m8Cfg2 = state.localIntegrations?.m8 || {};
871
899
  const marker = m8Cfg2.entryMarker;
872
- if (m8Cfg2.enabled === true && marker && (pendingCall.name === "pwsh" || pendingCall.name === "bash") && cmd.includes(marker)) {
900
+ // C1(批 3,2026-09-15):判据由「命令文本含 entryMarker」收紧为
901
+ // 「命令含 entryMarker **且 结果文本含落盘成功标志**」。
902
+ // 原判据不区分「跑了」与「跑成」:--dry-run(不写入)、写隔离夹具目录、脚本中途失败
903
+ // (工具层不标 isError)都会置位 manualWriteSeen → turn/end 误判 __engram-gap 并要求
904
+ // 同回合 engram_store(2026-09-15 会话实证:dry-run 亦被提示)。
905
+ // 成功标志走配置 successMarker(与 entryMarker 同风格;未配置时用统一入口的既有标志)。
906
+ const successMarker = m8Cfg2.successMarker || "MANUAL_WRITE_OK";
907
+ const m8ResultText = String(
908
+ resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
909
+ d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
910
+ ""
911
+ );
912
+ if (m8Cfg2.enabled === true && marker && (pendingCall.name === "pwsh" || pendingCall.name === "bash")
913
+ && cmd.includes(marker) && m8ResultText.includes(successMarker)) {
873
914
  s.turn.manualWriteSeen = true;
915
+ // D(2026-09-13):提示前移——落盘被识别的这一刻就提示(陈述式),不再等到 turn/end
916
+ //(turn/end 之后模型已停止行动,提示只能影响下一回合=事后才说,正是 M8 反复断链的结构原因)。
917
+ // 分工:此处=事中提示(预防);turn/end 的 engram-gap 审计保留,兜底投递仅在此处未提示时进行。
918
+ // 防重复:maybeInject 的闸是「同规则最多 3 次」(phase1f-inject.test.mjs 用例 5 为证),
919
+ // 并非一次即止 —— 故必须由回合标记 m8Hinted 保证「每个回合最多提示一次」。
920
+ // 默认关(hintOnWrite),与 M8 系列一贯的「显式启用」风格一致。
921
+ if (m8Cfg2.hintOnWrite === true && !s.turn.engramStoreSeen && !s.turn.m8Hinted) {
922
+ s.turn.m8Hinted = true;
923
+ maybeInject(ctx, sid, {
924
+ ruleId: "__engram-gap",
925
+ reason: getMessage("memory.sediment-hint")
926
+ });
927
+ }
874
928
  }
875
929
  if (pendingCall.name === "engram_store") {
876
930
  s.turn.engramStoreSeen = true;
@@ -1013,8 +1067,9 @@ export function handleSessionEvent(ctx, session, event) {
1013
1067
  if (output) s.lastCommandOutput = output;
1014
1068
  }
1015
1069
 
1016
- const pending = s.turn.pendingAsk;
1017
- if (pending) {
1070
+ const pending = s.pendingAsk;
1071
+ // D2 修复(2026-09-10):会话级待决 ask + 陈旧值保护(30 分钟未答视为作废,防跨回合误登记)
1072
+ if (pending && (!pending.at || Date.now() - pending.at <= 30 * 60 * 1000)) {
1018
1073
  const result = d.result ?? d.value ?? d;
1019
1074
  if (askResultApproved(result)) {
1020
1075
  s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
@@ -1024,7 +1079,12 @@ export function handleSessionEvent(ctx, session, event) {
1024
1079
  // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
1025
1080
  // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
1026
1081
  const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
1027
- const pathPrefix = inferPathPrefixFromText(scopeText || qText);
1082
+ // A2 修复(2026-09-10):原用单数版 inferPathPrefixFromText——只记一个路径且取"最长"。
1083
+ // 探针实测:多路径问题文本下"最长"往往是次要路径(如"去向 .backups/trash-x")而非主路径,
1084
+ // 主操作路径丢失 → 授权匹配必然失败。改为复数版:pathPrefix 取首个(按文本出现顺序=主路径,
1085
+ // 与用户消息侧 scopesFromIntents 的复数口径对齐),全部路径记入 pathPrefixes 供 authMatches 任一匹配。
1086
+ const pathPrefixes = inferPathPrefixesFromText(scopeText || qText);
1087
+ const pathPrefix = pathPrefixes[0] || "";
1028
1088
  // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
1029
1089
  // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
1030
1090
  const authRecord = {
@@ -1032,6 +1092,7 @@ export function handleSessionEvent(ctx, session, event) {
1032
1092
  pathPrefix,
1033
1093
  source: "ask"
1034
1094
  };
1095
+ if (pathPrefixes.length > 1) authRecord.pathPrefixes = pathPrefixes;
1035
1096
  const sessionWide = isSessionWideAskText(scopeText) || isSessionWideAskText(selectedText);
1036
1097
  if (sessionWide) authRecord.expiresAt = Date.now() + 12 * 60 * 60 * 1000;
1037
1098
  else if (!pathPrefix) authRecord.expiresAt = Date.now() + 2 * 60 * 1000;
@@ -1098,7 +1159,7 @@ export function handleSessionEvent(ctx, session, event) {
1098
1159
  session: sid
1099
1160
  });
1100
1161
  }
1101
- s.turn.pendingAsk = null;
1162
+ s.pendingAsk = null; // D2 修复(2026-09-10):清理**会话级**待决 ask
1102
1163
  }
1103
1164
  return;
1104
1165
  }
@@ -1181,9 +1242,12 @@ export function handleSessionEvent(ctx, session, event) {
1181
1242
  const critHit = violations.find((v) => v.mode === "criticism" && v.suspect);
1182
1243
  if (critHit) {
1183
1244
  s.turn.criticismSuspect = critHit.suspect;
1184
- if (critHit.suspect === "strong") {
1245
+ // C3(批 3,2026-09-15,用户裁定方案 A):冻结判定抽为纯函数——强信号下只有**完整执行许可**
1246
+ // (许可词 + 执行语)才豁免,避免把用户同一条消息里同时下达的指令一起冻掉。
1247
+ const freezeCrit = criticismFreezeBySignal(critHit.suspect, s.turn.userText || "");
1248
+ if (freezeCrit) {
1185
1249
  s.turn.criticismFrozen = true;
1186
- } else {
1250
+ } else if (critHit.suspect === "weak") {
1187
1251
  // 弱信号(2026-09-09 第三批后果分级):不冻结;含执行指令词则仅留痕不提示
1188
1252
  const execDirective = hasExplicitExecWord(s.turn.userText || "");
1189
1253
  if (!execDirective) {
@@ -1192,13 +1256,18 @@ export function handleSessionEvent(ctx, session, event) {
1192
1256
  reason: "用户消息疑似批评/质问形态(规则 22②,待确认):若确属批评——批评不构成任何授权,请先按归因四问产出三件套(事实/原因/方案)再等指令;若只是普通提问可忽略本条"
1193
1257
  });
1194
1258
  }
1259
+ } else {
1260
+ // C3:强信号 + 完整执行许可 → 不冻结,但**不静默放行**(留痕 + 陈述式提示;用户裁定「给一句提示」)
1261
+ maybeInject(ctx, sid, { ruleId: "22", reason: getMessage("criticism.strong-permitted") });
1195
1262
  }
1196
1263
  audit({
1197
1264
  kind: "criticism-suspect",
1198
1265
  rule: "22",
1199
1266
  name: "批评疑似信号(A″)",
1200
1267
  event: "assistant/message",
1201
- reason: critHit.suspect === "strong" ? "信号=strong——本回合写类工具冻结(A″ 强信号闸)" : "信号=weak——不冻结(后果分级:留痕+提示)",
1268
+ reason: critHit.suspect === "strong"
1269
+ ? (freezeCrit ? "信号=strong——本回合写类工具冻结(A″ 强信号闸)" : "信号=strong + 检出完整执行许可——不冻结(C3 方案 A 豁免,已提示)")
1270
+ : "信号=weak——不冻结(后果分级:留痕+提示)",
1202
1271
  session: sid
1203
1272
  });
1204
1273
  }
@@ -1252,7 +1321,7 @@ export function handleSessionEvent(ctx, session, event) {
1252
1321
 
1253
1322
  // ── /guard 子命令单一真源(0.5.13:hint 手写漏 tools 修复)──
1254
1323
  // hint(输入框提示)与 USAGE(完整帮助)均由此派生:未来新增子命令只改这一个数组。
1255
- const COMMAND_SPECS = [
1324
+ export const COMMAND_SPECS = [
1256
1325
  { name: "status", args: "", desc: "引擎状态" },
1257
1326
  { name: "rules", args: "", desc: "规则清单 + 理解产物" },
1258
1327
  { name: "active", args: "", desc: "最近激活的规则" },
@@ -1269,6 +1338,8 @@ const COMMAND_SPECS = [
1269
1338
  { name: "contract", args: "", desc: "查看当前任务契约" },
1270
1339
  { name: "contract categories", args: "...", desc: "设定契约类别白名单(0.5.12)" },
1271
1340
  { name: "label", args: "<id> <label>", desc: "给审计记录打标(correct/incorrect/inconclusive)" },
1341
+ // C4(批 3,2026-09-15):质量账本查询——机制在 core/quality-ledger.js,本项补的是**缺失的入口**
1342
+ { name: "quality", args: "", desc: "质量账本趋势(同类任务签名 × 返工/介入/摩擦/token)" },
1272
1343
  { name: "approve", args: "<type> <路径> [min]", desc: "物理确认:授予指定类型+路径的临时授权(仅用户输入;默认 10 分钟)" },
1273
1344
  { name: "freedom", args: "", desc: "查看守卫自由面(不拦什么)与盲区(看不见/判不准什么)(第三批 P1-2)" }
1274
1345
  ];
@@ -1287,7 +1358,7 @@ const USAGE = [
1287
1358
  // eslint-disable-next-line no-unused-vars
1288
1359
  const GUARD_HINT = "[" + [...new Set(COMMAND_SPECS.map((s) => s.name.split(" ")[0]))].join("|") + "]";
1289
1360
 
1290
- function parseCommand(rawInput) {
1361
+ export function parseCommand(rawInput) {
1291
1362
  const text = (rawInput || "").trim();
1292
1363
  if (!text || /^(status|state)$/i.test(text)) return { kind: "status" };
1293
1364
  if (/^(rules|list|ls)$/i.test(text)) return { kind: "rules" };
@@ -1316,6 +1387,8 @@ function parseCommand(rawInput) {
1316
1387
  if (parsed) return { kind: "budget", patch: parsed };
1317
1388
  }
1318
1389
  if (/^contract$/i.test(text)) return { kind: "contract" };
1390
+ // C4(批 3,2026-09-15):质量账本查询(白名单登记——未登记即落 invalid,见本函数上方注释)
1391
+ if (/^quality$/i.test(text)) return { kind: "quality" };
1319
1392
  // 0.5.12(F5 批次 3):/guard contract categories <build,test,install,...> 设定类别白名单
1320
1393
  m = text.match(/^contract\s+categories\s+(.+)$/i);
1321
1394
  if (m) {
@@ -1425,6 +1498,26 @@ async function executeGuard(ctx, invocation) {
1425
1498
  text: `已临时放行全部守卫 ${minutes} 分钟。到期自动恢复,也可 /guard reload 后立即恢复。`
1426
1499
  };
1427
1500
  }
1501
+ case "quality": {
1502
+ // C4(批 3,2026-09-15):质量账本查询入口。
1503
+ // 语义:账本默认关(rule-engine.json 的 qualityLedger.enabled=false)→ 无数据时**如实说明**、不伪造趋势;
1504
+ // 有数据时按签名分组各出趋势摘要。纯旁路统计:不拦截、不评分、不上传(数据留本机 ~/.dsh/quality-ledger.jsonl)。
1505
+ const qCfg = loadPluginConfig().qualityLedger || {};
1506
+ const qRows = loadLedger({ config: qCfg });
1507
+ if (qRows.length === 0) {
1508
+ return {
1509
+ kind: "success",
1510
+ text: qCfg.enabled
1511
+ ? "质量账本已开启,但暂无记录(账本只在任务收尾时记录;纯旁路统计,不拦截)"
1512
+ : "质量账本未开启(rule-engine.json 的 qualityLedger.enabled=false)——开启后按同类任务签名统计返工/介入/摩擦/token 趋势"
1513
+ };
1514
+ }
1515
+ const bySig = new Map();
1516
+ for (const r of qRows) bySig.set(r.sig, (bySig.get(r.sig) || 0) + 1);
1517
+ const top = [...bySig.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8);
1518
+ const body = top.map(([sig, n]) => ` ${sig} ${n} 单 ${qualityTrend(sig, { config: qCfg }).summary}`).join("\n");
1519
+ return { kind: "success", text: `质量账本:${qRows.length} 单 / ${bySig.size} 类签名\n${body}` };
1520
+ }
1428
1521
  case "approve": {
1429
1522
  // C4(2026-09-03)物理确认:用户亲手输入=词表无法误读;最小范围(类型+路径+时长);无全局通配
1430
1523
  if (!loadPluginConfig().approveEnabled) {
@@ -1890,7 +1983,7 @@ export function apply(ctx) {
1890
1983
 
1891
1984
  // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1892
1985
  // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1893
- // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1986
+ // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(该处用已扣 contract 重判 → 2+1>2 → 误拒)。
1894
1987
  // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
1895
1988
  ctx.on("tools/pre-execute", async (exec, next) => {
1896
1989
  try {
@@ -1974,7 +2067,9 @@ export function apply(ctx) {
1974
2067
  const sid = sessionIdOfExec(exec);
1975
2068
  const s = getSessionState(state, sid);
1976
2069
  if (!s?.turn?.criticismFrozen) return next();
1977
- const freeze = criticismFreezeDecision(s, exec?.name, exec?.arguments);
2070
+ // C2-C 档(批 3,2026-09-15):bypass 窗口内让步(语义=全部守卫暂停,与 guard status 文案一致);
2071
+ // 窗口内的变更类调用由 guard 路径既有的 bypass-action 审计统一留痕(M2),此处不重复记。
2072
+ const freeze = criticismFreezeDecision(s, exec?.name, exec?.arguments, { bypassActive: state.bypassUntil > Date.now() });
1978
2073
  if (!freeze) return next();
1979
2074
  audit({
1980
2075
  kind: "criticism-freeze",
@@ -1986,6 +2081,19 @@ export function apply(ctx) {
1986
2081
  reason: `疑似信号=${s.turn.criticismSuspect}(A″ 行为闸:批评不构成授权)`,
1987
2082
  session: sid
1988
2083
  });
2084
+ // 卡片数据源补齐(2026-09-11 P7):guard 路径(本文件的 guard 命中分支)在命中时写 cardHits,
2085
+ // 本闸此前只 audit → 卡片被记成「本回合无裁决事件」(verdict=clear),用户看不到这次拦截
2086
+ // —— 与 turn-card.js 注释声明的「全量记,含 __ 内部拦截」不符,属遗漏。
2087
+ try {
2088
+ (s.turn.cardHits ||= []).push({
2089
+ tool: exec?.name || "",
2090
+ args: summarizeArgs(exec?.arguments || {}),
2091
+ ruleId: "22",
2092
+ title: "批评回合冻结写类工具",
2093
+ reason: `疑似信号=${s.turn.criticismSuspect}(A″ 行为闸:批评不构成授权)`,
2094
+ errId: ""
2095
+ });
2096
+ } catch { /* 卡片是展示层增强,失败不影响拦截本身 */ }
1989
2097
  return {
1990
2098
  kind: "deny",
1991
2099
  reason: "[guardian:rule22] 【硬拦截】本回合命中批评/指错疑似信号——写类工具已冻结(A″ 行为闸)。请按四段模板回应:① 停止;② 归因四问;③ 三件套(事实/原因/方案);④ 等指令。只读操作不受限。"
package/lib/messages.js CHANGED
@@ -42,8 +42,13 @@ export const DEFAULT_MESSAGES = {
42
42
  // ── 批评检测引用(原 text-detect 引用本机手册章节号,1b 去本机化)──
43
43
  "criticism.strong": "User message matched a strong criticism/rebuke signal (rule 22②, pending adjudication): criticism is NOT authorization — write-class tools are frozen this turn. Respond with the four-part template: 1) stop; 2) attribution questions; 3) facts/cause/plan; 4) wait for instructions.",
44
44
  "criticism.weak": "User message looks like a criticism/challenge (rule 22②, pending adjudication): if it really is criticism — it is not authorization. Produce facts/cause/plan via the attribution questions, then wait. Ignore this note if it was just an ordinary question.",
45
+ // C3(批 3,2026-09-15,用户裁定方案 A):强信号 + 完整执行许可 → 不冻结,但**不静默放行**(留痕 + 本条提示)。
46
+ // 措辞须为陈述式:注入受 INJECT_COMMAND_RE 静态闸约束(不得含「请直接执行/请立即/立刻执行」等命令词)。
47
+ "criticism.strong-permitted": "User message matched a strong criticism signal BUT also carries a complete execution permission (approval word + action speech) — write-class tools are NOT frozen this turn and that permission stands. The criticism itself still grants nothing extra.",
45
48
  // ── M8 记忆沉淀提示(原 index.js 引用本机规则号,1b 去本机化)──
46
49
  "memory.sediment-missing": "This turn persisted manual/AGENTS changes through the configured entry script but did not call engram_store in the same turn; the memory-sediment chain should complete within one turn.",
50
+ // ── D(2026-09-13):提示前移到落盘那一刻(陈述式,不含命令词;区别于上面的 turn/end 违规判定文案)──
51
+ "memory.sediment-hint": "This turn persisted manual/AGENTS changes through the configured entry script; the memory-sediment chain expects an engram_store call in this same turn.",
47
52
  // ── 规则放行提示(原 guard-core RULE_HINTS,1c 迁出)──
48
53
  "rule-hint.1": "Analyze the root cause first, then continue once the problem is confirmed",
49
54
  "rule-hint.9": "Use a script file, or an explicit UTF-8 (no BOM) flow",
package/lib/service.js CHANGED
@@ -1,7 +1,14 @@
1
1
  // service.js —— 规则引擎 host 端 Remote 服务
2
2
  // 供设置面板(dsh-rules-manager-client 的“规则引擎”页签)通过 ctx.remote.ruleEngine.* 调用。
3
3
  // 与 rules-manager/service.js 同构:TypertRemoteService + 手动 @Remote 标记。
4
+ // 2026-09-14 修(B0-2 实测踩坑):readFile 必须来自 node:fs/promises——
5
+ // 从 node:fs 引的是回调版(第三参须为函数),按 Promise 写法 await readFile(p, "utf8")
6
+ // 会抛 `The "cb" argument must be of type function. Received type string ('utf8')`;
7
+ // 该异常被本文件 whitelistStatus 的 try/catch 吞进 debug.readError,外层仍回 ok:true
8
+ // → 面板把"读取失败"显示成"白名单为空"(静默弱化)。readFileSync 仍走 node:fs。
4
9
  import { readFileSync } from "node:fs";
10
+ import { readFile } from "node:fs/promises";
11
+ import { join } from "node:path";
5
12
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
13
  import { state } from "./core/runtime.js";
7
14
  import { readAuditLog, audit } from "./core/audit.js";
@@ -15,6 +22,7 @@ const REMOTE_METHODS = [
15
22
  "getVersion",
16
23
  "checkUpdate",
17
24
  "getAuditLog",
25
+ "whitelistStatus",
18
26
  "getUnderstanding",
19
27
  "getTaskContractConfig",
20
28
  "setTaskContractConfig",
@@ -178,6 +186,41 @@ class RuleEngineService extends TypertRemoteService {
178
186
  }
179
187
  }
180
188
 
189
+ /** 工具放行白名单状态(B0-2,2026-09-14 自 dsh-rules-manager 移植):
190
+ * 永久清单(`rule-engine-tools.json`)+ 近 24h 会话放行记录(从 `rule-engine.log.jsonl` 派生);
191
+ * `debug` 字段供面板白名单区异常时一眼定位。移植理由=引擎自带设置页(B0)不该依赖规则管理包。 */
192
+ async whitelistStatus() {
193
+ const home = dshHome();
194
+ const debug = { home };
195
+ const permanent = [];
196
+ try {
197
+ const raw = await readFile(join(home, "rule-engine-tools.json"), "utf8");
198
+ debug.fileLen = raw.length;
199
+ const arr = JSON.parse(raw);
200
+ if (Array.isArray(arr)) {
201
+ for (const it of arr) {
202
+ if (typeof it === "string") permanent.push({ name: it, time: null, session: null });
203
+ else if (it && typeof it.name === "string") permanent.push({ name: it.name, time: it.time ?? null, session: it.session ?? null });
204
+ }
205
+ }
206
+ } catch (e) { debug.readError = String((e && e.message) || e); }
207
+ const sessionAdded = [];
208
+ try {
209
+ const log = await readFile(join(home, "rule-engine.log.jsonl"), "utf8");
210
+ const cutoff = Date.now() - 24 * 3600 * 1000;
211
+ for (const line of log.split("\n")) {
212
+ if (!line.includes("unknown-tool-whitelist")) continue;
213
+ const name = line.match(/"reason":"[^"]*允许使用 ([A-Za-z_][A-Za-z0-9_:.-]*)/);
214
+ const ts = line.match(/"ts":"([^"]+)"/);
215
+ const sid = line.match(/"session":"([^"]+)"/);
216
+ if (name && sid && ts && new Date(ts[1]).getTime() >= cutoff) {
217
+ sessionAdded.push({ name: name[1], session: sid[1], time: ts[1] });
218
+ }
219
+ }
220
+ } catch { /* 无日志 = 无派生记录 */ }
221
+ return { ok: true, debug, permanent, sessionAdded: sessionAdded.slice(-50).reverse() };
222
+ }
223
+
181
224
  /** 规则理解摘要(ruleId/title/level/handler/confidence/actions) */
182
225
  async getUnderstanding() {
183
226
  try {
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.5",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,10 +11,6 @@
11
11
  },
12
12
  "files": [
13
13
  "lib",
14
- "scripts",
15
- "!scripts/local-residue-markers.txt",
16
- "!scripts/dualtrack-baseline.json",
17
- "upgrade-impact.json",
18
14
  "cordis.patch.yml",
19
15
  "README.md",
20
16
  "LICENSE"
@@ -44,14 +40,12 @@
44
40
  },
45
41
  "scripts": {
46
42
  "test": "node test/run-all.mjs",
47
- "check": "node --check lib/index.js",
48
- "verify": "node scripts/verify-all.mjs",
49
- "audit:mount": "node scripts/audit-mount-consistency.mjs --profile web",
50
- "check:meta": "node scripts/readme-version-check.mjs && node scripts/local-residue-scan.mjs && node scripts/dualtrack-check.mjs"
43
+ "check": "node --check lib/index.js"
51
44
  },
52
45
  "peerDependencies": {
53
46
  "@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.3 <0.2.0 || >=0.1.1-rc.0 <0.2.0 || >=0.1.2-rc.0 <0.2.0",
54
- "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3 <0.2.0 || >=0.1.1-rc.0 <0.2.0 || >=0.1.2-rc.0 <0.2.0"
47
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3 <0.2.0 || >=0.1.1-rc.0 <0.2.0 || >=0.1.2-rc.0 <0.2.0",
48
+ "dsh-rule-engine-client": ">=0.1.0 <0.2.0"
55
49
  },
56
50
  "dsh": {
57
51
  "bundle": {