dsh-rule-engine 0.5.10 → 0.5.11

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.
@@ -5,15 +5,21 @@ import {
5
5
  PROMISE_WORDS,
6
6
  SOURCE_MARK,
7
7
  TIME_WORDS,
8
+ EVIDENCE_MARK_RE,
8
9
  URL_RE,
9
10
  TECH_TERM_RE,
10
11
  TERM_EXPLANATION_RE,
11
12
  SUGGEST_RE,
12
13
  isNegatingSuggestion,
13
- isPromiseQuoteContext
14
+ isQuoteOrParaphraseContext
14
15
  } from "./patterns.js";
15
16
  import { detectOverengineeringText } from "./overengineering.js";
16
17
 
18
+ // F2(2026-08-28 阶段三):交付声明强模式(规则 23④ verify-gap 词面)——
19
+ // 裸"完成"太宽("完成社区检索/尚未完成/正在完成"误触)→ 强完成声明 + 否定/进行态排除。
20
+ // 模块级导出(纯函数模块,可测试锁定);LLM 裁决层(deliverSuspects)兜底不变。
21
+ export const DELIVERY_RE = /(?:已完成|全部(?:[^\s,。;!?]{0,12})完成|已[^\s,。;!?]{0,8}完成|修复完成|落盘完成|验证[^\s,。;!?]{0,6}(?:通过|成功)|全部通过|全部[^\s,。;!?]{0,10}通过|已通过|已修复|搞定)(?:\s*了|!|!|,[^。]*)?(?![^。]*(?:尚未|没有|未|没|还没|未完|待做|待完成))/;
22
+
17
23
  /** 从 assistant message 内容中提取纯文本 */
18
24
  export function extractAssistantText(message) {
19
25
  if (!message) return "";
@@ -21,8 +27,7 @@ export function extractAssistantText(message) {
21
27
  if (typeof content === "string") return content;
22
28
  if (Array.isArray(content)) {
23
29
  return content
24
- .map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : ""))
25
- .filter(Boolean)
30
+ .map((b) => (b && typeof b === "object" && b.type === "text" ? b.text : "")) .filter(Boolean)
26
31
  .join("\n");
27
32
  }
28
33
  return "";
@@ -30,6 +35,9 @@ export function extractAssistantText(message) {
30
35
 
31
36
  const SELF_CERT_HINTS = {
32
37
  "14": { re: /总结|汇报|完成/, reason: "请按规则 14 一次性完整汇报,区分事实与推断" },
38
+ // "22" 词表(text-detect):检测**我的回复文本**里的空话(B/D 级语义嫌疑),判定对象 = assistant 输出;
39
+ // 与 lexicon.js 的 ACTION/QUESTION/STATUS 等词表**不同层**(lexicon 判**用户消息**意图、服务规则 22 时序判定)。
40
+ // 二者用途不同、无重复:本表 = 输出侧"我说了空话";lexicon = 输入侧"用户说了什么"。改动需两处各自成对。
33
41
  "22": { re: /我记下了|记住了|收到,我记下了|放心,我记住了/i, reason: "检测到「我记下了」类空话;正确动作是落盘执行并汇报" },
34
42
  "23": { re: /完成|交付/, reason: "交付/完成声明未附运行时验证证据" },
35
43
  "16": { re: /建议|优化|更优方案|推理档位|档位/, reason: "请按规则 16 用绑定检查格式提建议(或给档位建议并说明理由),避免频繁打断" },
@@ -67,8 +75,42 @@ export function isSelfCertified(text, ruleId) {
67
75
  return re.test(text);
68
76
  }
69
77
 
78
+ /**
79
+ * 规则 2 时间核对的独立判定(F1,2026-08-28 阶段三):
80
+ * 原实现嵌在 detectViolations(assistant/message 时调)——但 Get-Date 工具常在本回合后续步骤
81
+ * 才执行(事故实弹:correct 04:52:23.061 早于 Get-Date 放行 .068)→ 真调了也判"未先核对"。
82
+ * 修复:判定时机延迟到 turn/end(getDateSeen 已定案),本判定函数只做判定、不依赖调用时机。
83
+ * @param {object} session 会话状态(turn.getDateSeen 已由 tool/call 置位)
84
+ * @param {string} text assistant 纯文本
85
+ * @param {object} timeCfg 规则 2 配置(byId.get("2"))
86
+ * @returns {Array<{ruleId,title,kind,reason}>}
87
+ */
88
+ export function detectTimeRule(session, text, timeCfg) {
89
+ if (!timeCfg || !TIME_WORDS.test(text) || isQuoteOrParaphraseContext(text, TIME_WORDS)) return [];
90
+ if (!session?.turn?.getDateSeen) {
91
+ return [{
92
+ ruleId: "2",
93
+ title: timeCfg.title,
94
+ kind: "correct",
95
+ reason: "回答出现具体时间词/日期,但本回合未先调用 Get-Date 核对"
96
+ }];
97
+ }
98
+ if (!EVIDENCE_MARK_RE.test(text)) {
99
+ return [{
100
+ ruleId: "2",
101
+ title: timeCfg.title,
102
+ kind: "correct",
103
+ reason: "回答含具体时间词但未附事件证据标注(日志 ts/文件 mtime/进程启动时间等)——Get-Date 当前时间不算过去事件证据(规则 2②)。正确动作:查该事件证据补(来源:…)或删时间词/标【时间未核实】,勿以当前时间替代"
104
+ }];
105
+ }
106
+ return [];
107
+ }
108
+
70
109
  /**
71
110
  * 检测一次 assistant/message 的 B/D 级违规。
111
+ * 规则 2 时间核对不在本函数内(F1,2026-08-28):原实现在此判定,但 Get-Date 工具常在本回合
112
+ * 后续步骤才执行(事故实弹:correct 早于 Get-Date 放行)→ 真调了也判"未先核对"。
113
+ * 现由 index.js 在 turn/end 时机调用 detectTimeRule(getDateSeen 定案后)。
72
114
  * @param {object} options
73
115
  * @param {Array} options.configs 理解配置
74
116
  * @param {object} options.session 会话状态(getSessionState 返回)
@@ -80,18 +122,32 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
80
122
  const byId = new Map(configs.filter((c) => c.confidence !== "low").map((c) => [String(c.ruleId), c]));
81
123
 
82
124
  const timeCfg = byId.get("2");
83
- if (timeCfg && TIME_WORDS.test(text) && !session.turn.getDateSeen) {
84
- hits.push({
85
- ruleId: "2",
86
- title: timeCfg.title,
87
- kind: "correct",
88
- reason: "回答出现时间词/日期,但本回合未先调用 Get-Date 核对"
89
- });
125
+ // v0.5.11(用户定稿):① 只命中具体时间词(TIME_WORDS 为具体词表——"之前/当时"等模糊词不命中,不新增);
126
+ // ② 具体时间词 + 无事件证据标注(日志 ts/文件 mtime/进程启动时间等)→ 违规,Get-Date 当前时间不算事件证据。
127
+ // F1(2026-08-28 阶段三):本检测仅作"判定",投递时机由调用方在 turn/end 复核(见 detectTimeRule 注释)。
128
+ // B3(2026-08-29):引述/转述语境的时间词不触发("你昨天说…"是转述,不是我的时间表述);
129
+ // 第一人称"我说昨天…"仍触发(转述不了自己)。
130
+ if (timeCfg && TIME_WORDS.test(text) && !isQuoteOrParaphraseContext(text, TIME_WORDS)) {
131
+ if (!session.turn.getDateSeen) {
132
+ hits.push({
133
+ ruleId: "2",
134
+ title: timeCfg.title,
135
+ kind: "correct",
136
+ reason: "回答出现具体时间词/日期,但本回合未先调用 Get-Date 核对"
137
+ });
138
+ } else if (TIME_WORDS.test(text) && !EVIDENCE_MARK_RE.test(text)) {
139
+ hits.push({
140
+ ruleId: "2",
141
+ title: timeCfg.title,
142
+ kind: "correct",
143
+ reason: "回答含具体时间词但未附事件证据标注(日志 ts/文件 mtime/进程启动时间等)——Get-Date 当前时间不算过去事件证据(规则 2②)"
144
+ });
145
+ }
90
146
  }
91
147
 
92
148
  const promiseCfg = byId.get("7");
93
149
  // v0.5.7 P0.5-5:承诺词处于引述/改写语境("把'保证'改成…")→ 引述不是承诺,不触发
94
- if (promiseCfg && PROMISE_WORDS.test(text) && !isPromiseQuoteContext(text)) {
150
+ if (promiseCfg && PROMISE_WORDS.test(text) && !isQuoteOrParaphraseContext(text, PROMISE_WORDS)) {
95
151
  hits.push({
96
152
  ruleId: "7",
97
153
  title: promiseCfg.title,
@@ -51,6 +51,9 @@ const ARTIFACT_TOOLS = new Set([
51
51
  // 官方 plan-mode 交互工具(参数为 markdown 计划,批准/继续规划,无文件副作用)
52
52
  "exit_plan_mode",
53
53
  // 开发侧 staging(写 staging 区,dev_* 工具的自然行为)
54
+ // 0.5.11 修正(手册 v3.39 口径):dev_stage 四件套(add/call/promote/demote)= 纳入**敏感
55
+ // 授权检查**(守卫链覆盖),**不是改分类**——统一保持 artifact;授权检查由 guard-core
56
+ // (12A isSensitiveToolCall / 13A / 24 装配判定)链上覆盖,不再各自归类。
54
57
  "dev_stage_add", "dev_stage_call", "dev_stage_promote", "dev_stage_demote",
55
58
  // ESR 工程状态写操作(写工作区记忆存储,engram_store 同族)
56
59
  "esr_task", "esr_node", "esr_link", "esr_claim", "esr_close", "esr_unclaim", "esr_dep", "esr_gc"
@@ -26,7 +26,7 @@ const HANDLER_BY_RULE = {
26
26
  "12C": "rule12c-network",
27
27
  "13A": "rule13a-backup",
28
28
  // 13B 已于 2026-08-24 外移至手册(规则正文删除,映射一并清理,防死映射)
29
- 14: "rule14-report",
29
+ // 14 为纯 D 级自证规则,无对应 handler(0.5.11:删除空转映射 registry)
30
30
  18: "rule18-manual-first",
31
31
  21: "rule21-meta",
32
32
  22: "rule22-7-direct",
@@ -129,7 +129,7 @@ function isOrderedLineInsertion(oldString, newString) {
129
129
  }
130
130
 
131
131
  /** 校验 append/删除式编辑:新增需包含 old_string;删除/缩短时 new_string 可为 old_string 的子串;版本行重编号放行;非表格单行修改放行 */
132
- export function validateEditAppend(oldString, newString) {
132
+ export function validateEditAppend(oldString, newString, uniqueMatch = false) {
133
133
  if (typeof oldString !== "string" || typeof newString !== "string") return { ok: true, errors: [] };
134
134
  if (oldString.length === 0) return { ok: true, errors: [] };
135
135
  if (isVersionRenumber(oldString, newString)) return { ok: true, errors: [] };
@@ -139,6 +139,9 @@ export function validateEditAppend(oldString, newString) {
139
139
  if (isSameLineReplacement(oldString, newString)) return { ok: true, errors: [] };
140
140
  if (isTableRowReplacement(oldString, newString)) return { ok: true, errors: [] };
141
141
  if (isOrderedLineInsertion(oldString, newString)) return { ok: true, errors: [] };
142
+ // 0.5.11(用户定稿):单行整句重写放行——**前提 = old 在原文件唯一匹配**(调用方实测传入);
143
+ // edit 工具语义已保证唯一才替换、位置正确;唯一下单行重写 = 正常语义编辑,不再是"覆盖"。
144
+ if (uniqueMatch && isSingleLine(oldString) && isSingleLine(newString)) return { ok: true, errors: [] };
142
145
  return {
143
146
  ok: false,
144
147
  errors: ["new_string 未包含 old_string,疑似覆盖上一行"]
@@ -146,11 +149,11 @@ export function validateEditAppend(oldString, newString) {
146
149
  }
147
150
 
148
151
  /** 综合校验一次编辑后的文件 */
149
- export function validateEditedFile(originalText, currentText, oldString, newString) {
152
+ export function validateEditedFile(originalText, currentText, oldString, newString, uniqueMatch = false) {
150
153
  const errors = [];
151
154
  const continuity = validateVersionContinuity(currentText);
152
155
  if (!continuity.ok) errors.push(...continuity.errors);
153
- const append = validateEditAppend(oldString, newString);
156
+ const append = validateEditAppend(oldString, newString, uniqueMatch);
154
157
  if (!append.ok) errors.push(...append.errors);
155
158
  return { ok: errors.length === 0, errors };
156
159
  }
package/lib/index.js CHANGED
@@ -34,7 +34,9 @@ import {
34
34
  askQuestionCoreText,
35
35
  askQuestionText,
36
36
  askResultApproved,
37
+ askResultRejected,
37
38
  askResultSelectedText,
39
+ classifyAskScopeType,
38
40
  inferPathPrefixFromText,
39
41
  inferTypeFromText,
40
42
  isAuthMessage,
@@ -58,9 +60,9 @@ import {
58
60
  import { toolClass } from "./core/tool-catalog.js";
59
61
  import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
60
62
  import { state } from "./core/runtime.js";
61
- import { detectViolations, extractAssistantText } from "./core/text-detect.js";
63
+ import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
62
64
  import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
63
- import { judgeViolation } from "./core/judge.js";
65
+ import { judgeViolation, judgeViolationsBatch } from "./core/judge.js";
64
66
  import {
65
67
  applyContract,
66
68
  contractSummary,
@@ -98,6 +100,18 @@ reloadRules(state);
98
100
 
99
101
  // ── 工具函数 ────────────────────────────────────────────────────────────────
100
102
 
103
+ /** 统计 substring 出现次数(0.5.11 唯一性前提;与 guard-core countOccurrences 同语义) */
104
+ function countOccurrencesStr(text, sub) {
105
+ if (typeof text !== "string" || typeof sub !== "string" || sub.length === 0) return 0;
106
+ let count = 0;
107
+ let idx = text.indexOf(sub);
108
+ while (idx !== -1) {
109
+ count++;
110
+ idx = text.indexOf(sub, idx + sub.length);
111
+ }
112
+ return count;
113
+ }
114
+
101
115
  function summarizeArgs(args) {
102
116
  try {
103
117
  const s = JSON.stringify(args ?? {});
@@ -178,6 +192,21 @@ const pendingInjectTimers = new Set();
178
192
  function maybeInject(ctx, sessionId, violation) {
179
193
  if (!pluginConfig.correctInject) return;
180
194
  const key = `${sessionId}:${violation.ruleId}`;
195
+ // D1(2026-08-28 阶段三):注入文案命令词静态检查——[规则引擎] 注入若含"请直接执行/不要再…
196
+ // 请立即…"类命令式短语,会驱动模型越过用户直接行动(实弹:节流注入后模型跳过用户回复);
197
+ // 注入只许陈述事实。命中 = 拒绝投递 + 审计留痕(防本次修复被下次改文案时回潮)。
198
+ const INJECT_COMMAND_RE = /请直接执行|请立即|不要再|勿再|请马上|现在就做|立刻执行|直接执行/;
199
+ if (INJECT_COMMAND_RE.test(String(violation?.reason || ""))) {
200
+ audit({
201
+ kind: "inject-command-gated",
202
+ rule: violation.ruleId,
203
+ name: "注入文案命令词拦截(D1)",
204
+ event: "inject",
205
+ reason: `注入文案含命令式短语,已拒绝投递(只许陈述事实):${String(violation.reason || "").slice(0, 100)}`,
206
+ session: sessionId
207
+ });
208
+ return;
209
+ }
181
210
  // v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
182
211
  // 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
183
212
  // 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
@@ -233,7 +262,9 @@ function maybeInject(ctx, sessionId, violation) {
233
262
  });
234
263
  audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
235
264
  } else {
236
- audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递:agents.get(${sessionId}) 无可用 agent.inject`, session: sessionId });
265
+ // D2(2026-08-28 阶段三):不可投递 agent(子代理/已销毁会话)——仅审计降噪留痕,
266
+ // reason 注明"预期降噪"便于 /guard log 区分真实失败;不再产生可见投递噪音。
267
+ audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入未投递(D2 预期降噪):agents.get(${sessionId}) 无可用 agent.inject——仅审计留痕`, session: sessionId });
237
268
  }
238
269
  } catch (error) {
239
270
  audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
@@ -249,35 +280,67 @@ function maybeInject(ctx, sessionId, violation) {
249
280
  * judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
250
281
  */
251
282
  async function deliverSuspects(ctx, sessionId, suspects, text) {
283
+ // 0.5.11(用户定稿,预算打穿修复):一次回复的全部嫌疑 = 一次批量裁决(一次 LLM/一条预算)
284
+ // 此前逐条 judgeViolation(每条一次调用/一条预算)导致 50/日早上耗尽(unavailable 89 条实证)。
252
285
  const judgeFn = state.judgeFn || judgeViolation;
253
- const results = await Promise.all(suspects.map(async (v) => {
254
- try {
255
- const verdict = await judgeFn(ctx, state, sessionId, v, text);
256
- const kind = verdict.action === "deliver" ? "judge-pass"
257
- : verdict.action === "suppress" ? "judge-false"
258
- : "judge-unavailable";
259
- audit({
260
- kind,
261
- rule: v.ruleId,
262
- name: "违规裁决",
263
- event: "assistant/message",
264
- reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
265
- session: sessionId
266
- });
267
- return verdict.action === "deliver" ? v : null;
268
- } catch (error) {
269
- audit({
270
- kind: "judge-unavailable",
271
- rule: v.ruleId,
272
- name: "裁决异常",
273
- event: "assistant/message",
274
- reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
275
- session: sessionId
286
+ // 注入版/单条路径保持兼容:state.judgeFn(测试 stub)或仅 1 条嫌疑 → 单项路径
287
+ if (suspects.length <= 1 || state.judgeFn) {
288
+ const results = await Promise.all(suspects.map(async (v) => {
289
+ try {
290
+ const verdict = await judgeFn(ctx, state, sessionId, v, text);
291
+ const kind = verdict.action === "deliver" ? "judge-pass"
292
+ : verdict.action === "suppress" ? "judge-false"
293
+ : "judge-unavailable";
294
+ audit({
295
+ kind,
296
+ rule: v.ruleId,
297
+ name: "违规裁决",
298
+ event: "assistant/message",
299
+ reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
300
+ session: sessionId
301
+ });
302
+ return verdict.action === "deliver" ? v : null;
303
+ } catch (error) {
304
+ audit({
305
+ kind: "judge-unavailable",
306
+ rule: v.ruleId,
307
+ name: "裁决异常",
308
+ event: "assistant/message",
309
+ reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
310
+ session: sessionId
311
+ });
312
+ return null;
313
+ }
314
+ }));
315
+ const deliver = results.filter(Boolean);
316
+ if (deliver.length > 1) {
317
+ const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
318
+ maybeInject(ctx, sessionId, {
319
+ ruleId: "__self-cert",
320
+ reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
276
321
  });
277
- return null;
322
+ } else if (deliver.length === 1) {
323
+ maybeInject(ctx, sessionId, deliver[0]);
278
324
  }
279
- }));
280
- const deliver = results.filter(Boolean);
325
+ return;
326
+ }
327
+ // 批量路径:一次 judgeViolationsBatch(一次调用/一次预算/逐条判定)
328
+ const batch = await judgeViolationsBatch(ctx, state, sessionId, suspects, text);
329
+ const deliver = [];
330
+ for (const { violation: v, verdict } of batch) {
331
+ const kind = verdict.action === "deliver" ? "judge-pass"
332
+ : verdict.action === "suppress" ? "judge-false"
333
+ : "judge-unavailable";
334
+ audit({
335
+ kind,
336
+ rule: v.ruleId,
337
+ name: "违规裁决",
338
+ event: "assistant/message",
339
+ reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
340
+ session: sessionId
341
+ });
342
+ if (verdict.action === "deliver") deliver.push(v);
343
+ }
281
344
  if (deliver.length > 1) {
282
345
  const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
283
346
  maybeInject(ctx, sessionId, {
@@ -353,9 +416,35 @@ export function handleSessionEvent(ctx, session, event) {
353
416
  });
354
417
  maybeInject(ctx, sid, {
355
418
  ruleId: "__engram-gap",
356
- reason: "规则 19/M8:手册/AGENTS 落盘后必须在同一回合补 engram_store,否则记忆机制断链——请立即补写并说明"
419
+ reason: "规则 19/M8:手册/AGENTS 落盘后应在同一回合补 engram_store,否则记忆机制断链(已记审计)"
357
420
  });
358
421
  }
422
+ // F1(2026-08-28 阶段三):规则 2 时序竞态修复——assistant/message 检测到时间词违规时
423
+ // 不立即投递(Get-Date 工具常在本回合后续步骤才执行,事故实弹:correct 早于 Get-Date 放行),
424
+ // 标记 pendingRule2,turn/end 复核:getDateSeen 已定案——若本回合最终调用过 Get-Date,撤销(不投递)。
425
+ const p2 = s.turn.pendingRule2;
426
+ if (p2) {
427
+ if (s.turn.getDateSeen) {
428
+ audit({
429
+ kind: "rule2-resolved",
430
+ rule: "2",
431
+ name: "规则2回合末复核(已核对)",
432
+ event: "turn/end",
433
+ reason: "回合内最终已调用 Get-Date(getDateSeen=true)——assistant/message 时误报撤销,不投递",
434
+ session: sid
435
+ });
436
+ } else {
437
+ audit({
438
+ kind: "correct",
439
+ rule: "2",
440
+ name: "时间信息须真实(执行等级:B + D)",
441
+ event: "turn/end",
442
+ reason: p2
443
+ });
444
+ maybeInject(ctx, sid, { ruleId: "2", reason: p2 });
445
+ }
446
+ s.turn.pendingRule2 = null;
447
+ }
359
448
  return;
360
449
  }
361
450
  if (event.type === "user/message") {
@@ -423,8 +512,10 @@ export function handleSessionEvent(ctx, session, event) {
423
512
  // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
424
513
  s.turn.scopes = scopesFromIntents(s.turn.intents);
425
514
  // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
515
+ // A2(阶段一):promise 记录到 turn——pre-execute 拦截点可同步等待,防"预取未回就按词表拦"竞态
426
516
  if (pluginConfig.llmIntent?.enabled) {
427
- void enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
517
+ const p = enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
518
+ s.turn.llmIntentPromise = p;
428
519
  }
429
520
  if (state.taskContract?.taskContractEnabled) {
430
521
  const patch = naturalMode(text, s.contract);
@@ -637,17 +728,30 @@ export function handleSessionEvent(ctx, session, event) {
637
728
  const auditCmd = pendingCall.args?.command || pendingCall.args?.code || "";
638
729
  if (isAuditCommand(auditCmd)) {
639
730
  const output = extractToolOutput(d);
640
- if (isError || auditOutputFailed(output)) {
731
+ if (auditOutputFailed(output)) {
641
732
  audit({
642
733
  kind: "mount-audit-fail",
643
734
  rule: "27",
644
735
  name: "全量审计未通过",
645
736
  event: "tool/result",
646
- reason: isError ? "审计脚本执行失败" : "审计脚本发现 DUPLICATES FOUND",
737
+ reason: "审计脚本发现 DUPLICATES FOUND",
647
738
  session: sid,
648
739
  tool: pendingCall.name
649
740
  });
650
741
  maybeInject(ctx, sid, { ruleId: "27", reason: "规则 27:全量审计未通过,先移除多余挂载再重跑审计" });
742
+ } else if (isError) {
743
+ // 2026-08-29 A1:审计命令未执行成功(被规则 22 拦截/工具错误,无审计输出)≠ 审计发现 DUPLICATES——
744
+ // 不注入"先移除多余挂载"(误导:审计根本没执行;历史误报曾引导模型误移除正确挂载)。
745
+ // 保持 dirty(审计未跑成,不知道结果);留痕 mount-audit-error 供 /guard log 排查。
746
+ audit({
747
+ kind: "mount-audit-error",
748
+ rule: "27",
749
+ name: "审计命令未执行成功",
750
+ event: "tool/result",
751
+ reason: "审计脚本被拦截或执行失败(无审计输出);请先处理拦截原因后重跑审计",
752
+ session: sid,
753
+ tool: pendingCall.name
754
+ });
651
755
  } else if (auditOutputPassed(output)) {
652
756
  s.mountAuditRevision = state.mountRevision;
653
757
  s.mountAuditSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
@@ -669,11 +773,15 @@ export function handleSessionEvent(ctx, session, event) {
669
773
  if (!isError && pendingCall && pendingCall.originalContent != null && versionTarget && isVersionedFile(versionTarget)) {
670
774
  try {
671
775
  const current = readFileSync(versionTarget, "utf8");
776
+ // 0.5.11:唯一性透传(与 guard-core 同口径——old 唯一匹配才算"单行重写"放行前提)
777
+ const oldStr = pendingCall.args.old_string || pendingCall.args.old_str || "";
778
+ const uniqueMatch = oldStr.length > 0 ? countOccurrencesStr(pendingCall.originalContent, oldStr) === 1 : false;
672
779
  const check = validateEditedFile(
673
780
  pendingCall.originalContent,
674
781
  current,
675
- pendingCall.args.old_string || "",
676
- pendingCall.args.new_string || ""
782
+ oldStr,
783
+ pendingCall.args.new_string || pendingCall.args.new_str || "",
784
+ uniqueMatch
677
785
  );
678
786
  if (!check.ok) {
679
787
  writeFileSync(versionTarget, pendingCall.originalContent, "utf8");
@@ -745,10 +853,10 @@ export function handleSessionEvent(ctx, session, event) {
745
853
  // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
746
854
  const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
747
855
  const pathPrefix = inferPathPrefixFromText(scopeText || qText);
748
- // ask 问题文本措辞不可靠,授权记录为宽泛类型 any + 路径前缀,避免类型错位
749
- // 无路径的全局 any 授权缩短 TTL,降低安全边界风险
856
+ // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
857
+ // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
750
858
  const authRecord = {
751
- type: "any",
859
+ type: classifyAskScopeType(scopeText || qText),
752
860
  pathPrefix,
753
861
  source: "ask"
754
862
  };
@@ -790,8 +898,11 @@ export function handleSessionEvent(ctx, session, event) {
790
898
  reason: `记录授权范围:${qText.slice(0, 120)}`,
791
899
  session: sid
792
900
  });
793
- } else {
794
- // 弹窗消减(2026-08-24):本回合 ask 被拒 → 标记 + 全局记录(5 分钟内再 ask 会被 __ask-throttle 拦)
901
+ } else if (askResultRejected(result)) {
902
+ // 弹窗消减(2026-08-24):本回合 ask 被【明确拒绝】(用户点了拒绝/否定词)→ 标记 + 全局记录
903
+ // C3(2026-08-28 阶段二):未响应/超时/无选择不再记为拒绝——"用户没时间回复"不是拒绝,
904
+ // 旧逻辑把任何未批准都入 askRejections 池 → 5 分钟内再 ask 被节流,用户在忙时反而被烧掉
905
+ // 授权通道(实弹:用户"你也没给我时间回复啊")。敏感操作仍由 12A 授权证据把关(不降级)。
795
906
  s.turn.askRejected = true;
796
907
  if (!state.askRejections) state.askRejections = [];
797
908
  state.askRejections.push({ sessionId: sid, at: Date.now() });
@@ -801,7 +912,17 @@ export function handleSessionEvent(ctx, session, event) {
801
912
  rule: "12D",
802
913
  name: "ask_user_question 未授权",
803
914
  event: "tool/result",
804
- reason: "用户未批准该授权请求",
915
+ reason: "用户明确拒绝该授权请求(拒绝词命中,计入节流池)",
916
+ session: sid
917
+ });
918
+ } else {
919
+ // C3:未响应/超时/无明确选择 → 不置 askRejected、不入节流池(仅审计留痕)
920
+ audit({
921
+ kind: "auth-noanswer",
922
+ rule: "12D",
923
+ name: "ask_user_question 未响应",
924
+ event: "tool/result",
925
+ reason: "ask 无结果/未响应(用户可能未及回复)——不计入拒绝节流池;后续可再次询问",
805
926
  session: sid
806
927
  });
807
928
  }
@@ -822,7 +943,9 @@ export function handleSessionEvent(ctx, session, event) {
822
943
  // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
823
944
  if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
824
945
  // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
825
- if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
946
+ // F2(2026-08-28 阶段三):词面收紧——"完成"裸词太宽("完成社区检索/尚未完成/正在完成"均误触),
947
+ // 改为强完成声明模式且排除否定/进行态;仍由 LLM 裁决层兜底(deliverSuspects)。
948
+ if (DELIVERY_RE.test(text)) {
826
949
  const windowStart = Date.now() - 30 * 60 * 1000;
827
950
  const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
828
951
  if (!hasPass) {
@@ -875,7 +998,13 @@ export function handleSessionEvent(ctx, session, event) {
875
998
  });
876
999
  }
877
1000
  }
878
- const violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
1001
+ // F1(2026-08-28 阶段三):规则 2 违规不在此时投递——标记 pendingRule2,turn/end 复核(Get-Date 定案)①
1002
+ let violations = detectViolations({ configs: state.configs, session: s, text, reasoningText: s.turn.reasoningText, mountRevision: state.mountRevision });
1003
+ const rule2s = violations.filter((v) => v.ruleId === "2");
1004
+ for (const v of rule2s) {
1005
+ if (!s.turn.pendingRule2) s.turn.pendingRule2 = v.reason;
1006
+ violations = violations.filter((x) => x !== v);
1007
+ }
879
1008
  for (const v of violations) {
880
1009
  audit({
881
1010
  kind: v.kind,
@@ -1074,7 +1203,7 @@ async function executeGuard(ctx, invocation) {
1074
1203
  continue;
1075
1204
  }
1076
1205
  const t = (e.ts || "").replace("T", " ").slice(0, 19);
1077
- parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}${e.eventId ? `(${e.eventId})` : ""}`);
1206
+ parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}${e.errId ? `(ERR-${e.errId})` : ""}${e.eventId ? `(${e.eventId})` : ""}`);
1078
1207
  if (e.reason) parts.push(` 原因:${e.reason}`);
1079
1208
  if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
1080
1209
  }
@@ -1153,11 +1282,17 @@ async function executeGuard(ctx, invocation) {
1153
1282
  }
1154
1283
  case "label": {
1155
1284
  const entries = readAuditLog(500);
1156
- const found = entries.find((e) => e.eventId === command.eventId);
1157
- if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}` };
1158
- state.labels.set(command.eventId, command.label);
1159
- audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${command.eventId} = ${command.label}`, session: invocation?.session?.id || "global" });
1160
- return { kind: "success", text: `已标注 ${command.eventId} = ${command.label}` };
1285
+ // E1(2026-08-28 阶段一):支持 ERR-xxxxxx 短码定位(此前只认 eventId UUID——
1286
+ // 用户拿拦截提示里的 ERR 码无法打标,链路断在"两套标识符无映射")
1287
+ const want = String(command.eventId || "").trim();
1288
+ const normalized = want.toUpperCase().startsWith("ERR-") ? want.slice(4).toUpperCase() : want;
1289
+ const found = entries.find(
1290
+ (e) => e.eventId === want || e.errId === normalized || e.errId === want || `ERR-${e.errId}` === want.toUpperCase()
1291
+ );
1292
+ if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}(提示:/guard log 可查最近记录;若为旧日志则拦截时未记录 ERR 码)` };
1293
+ state.labels.set(found.eventId, command.label);
1294
+ audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${found.eventId} = ${command.label}(来源 ERR-${found.errId || "?"})`, session: invocation?.session?.id || "global" });
1295
+ return { kind: "success", text: `已标注 ${found.eventId} = ${command.label}` };
1161
1296
  }
1162
1297
  default:
1163
1298
  return { kind: "error", text: USAGE };
@@ -1309,6 +1444,7 @@ export function apply(ctx) {
1309
1444
  tool: exec?.name,
1310
1445
  args: summarizeArgs(exec?.arguments),
1311
1446
  reason: hit.reason,
1447
+ errId: hit.errId,
1312
1448
  session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
1313
1449
  });
1314
1450
  state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
@@ -1321,10 +1457,12 @@ export function apply(ctx) {
1321
1457
  if (oldest !== undefined) state.deniedKeys.delete(oldest);
1322
1458
  }
1323
1459
  // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1460
+ // D1(2026-08-28 阶段三):改**陈述式**——旧文案"请直接执行"是命令,模型照做会越过
1461
+ // 用户(实弹:注入后模型跳过用户回复直接执行);只陈述事实,决策权留给用户。
1324
1462
  if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1325
1463
  maybeInject(ctx, sessionIdOfExec(exec), {
1326
1464
  ruleId: hit.ruleId,
1327
- reason: `${hit.title}:已有授权或询问被拒时请直接执行、或用普通文本说明,不要再弹窗 ask。`
1465
+ reason: `${hit.title}(规则 ${hit.ruleId}):本会话已存在相近授权记录或被拒记录;再次弹窗询问可能无法送达用户。可用普通文本说明。`
1328
1466
  });
1329
1467
  }
1330
1468
  return hit.reason;
@@ -1336,6 +1474,25 @@ export function apply(ctx) {
1336
1474
  );
1337
1475
 
1338
1476
  // 1.0 规则 12C B 级留痕:命令文本含回环/内网地址时记录审计,不拦截
1477
+ // A2(阶段一,2026-08-28):LLM 意图兜底同步等待——词表判"拦"(denyMutation)时,若 LLM
1478
+ // 预取仍在进行(llm-pending),在工具执行前同步等待结果(上限 300ms,超时按词表保守裁决)。
1479
+ // 根因:enrichIntentWithLlm 是异步预取,模型先发工具调用往往先于 LLM 返回 → 词表盲区
1480
+ // (转化/读取/展示 等未收录词)在拦截点直接判拦,LLM 兜底形同虚设(2026-08-28 实弹)。
1481
+ ctx.on("tools/pre-execute", async (exec, next) => {
1482
+ try {
1483
+ const sid2 = sessionIdOfExec(exec);
1484
+ const s2 = getSessionState(state, sid2);
1485
+ if (s2?.turn?.intentState === "llm-pending" && typeof s2.turn?.llmIntentPromise?.then === "function") {
1486
+ await Promise.race([
1487
+ s2.turn.llmIntentPromise,
1488
+ new Promise((resolve) => setTimeout(resolve, 300))
1489
+ ]);
1490
+ }
1491
+ } catch {
1492
+ // 等待失败不阻断:词表裁决兜底
1493
+ }
1494
+ return next();
1495
+ });
1339
1496
  ctx.on("tools/pre-execute", async (exec, next) => {
1340
1497
  try {
1341
1498
  const cmd = exec?.arguments?.command || exec?.arguments?.code || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.5.10",
3
+ "version": "0.5.11",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,3 @@
1
+ // probe-home.mjs - 实测 resolveDshHome 解析结果(同 host 用的 @deepseek-ai/dsh-home-paths)
2
+ import { resolveDshHome } from "@deepseek-ai/dsh-home-paths";
3
+ console.log("resolveDshHome():", resolveDshHome());
@@ -0,0 +1,14 @@
1
+ // probe-intent.mjs - 测"落"字为何被判问句(用户明确执行却拦)
2
+ import { parseUserIntents, shouldDenyMutation } from "../lib/core/intent.js";
3
+ const texts = [
4
+ "2、连同上面这条一起落",
5
+ "连上面这条一起落",
6
+ "落",
7
+ "全部一起落",
8
+ "认可全部link不装npm形态",
9
+ "1、认可形态 2、连上面这条一起落"
10
+ ];
11
+ for (const t of texts) {
12
+ const r = parseUserIntents(t);
13
+ console.log(`"${t}" → hasExecute=${r.hasExecute} hasQuestion=${r.hasQuestion} types=${r.clauses.map((c) => c.type).join(",")}`);
14
+ }