dsh-rule-engine 0.5.9 → 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
@@ -23,6 +23,7 @@ import {
23
23
  isGetDateCommand,
24
24
  isManualReadTool,
25
25
  isReadOnlyTool,
26
+ matchKnownPitfall,
26
27
  needsApprovalReminder,
27
28
  PRIVATE_NETWORK_RE,
28
29
  setWorkspaceRoot,
@@ -33,7 +34,9 @@ import {
33
34
  askQuestionCoreText,
34
35
  askQuestionText,
35
36
  askResultApproved,
37
+ askResultRejected,
36
38
  askResultSelectedText,
39
+ classifyAskScopeType,
37
40
  inferPathPrefixFromText,
38
41
  inferTypeFromText,
39
42
  isAuthMessage,
@@ -57,9 +60,9 @@ import {
57
60
  import { toolClass } from "./core/tool-catalog.js";
58
61
  import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
59
62
  import { state } from "./core/runtime.js";
60
- import { detectViolations, extractAssistantText } from "./core/text-detect.js";
63
+ import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
61
64
  import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
62
- import { judgeViolation } from "./core/judge.js";
65
+ import { judgeViolation, judgeViolationsBatch } from "./core/judge.js";
63
66
  import {
64
67
  applyContract,
65
68
  contractSummary,
@@ -97,6 +100,18 @@ reloadRules(state);
97
100
 
98
101
  // ── 工具函数 ────────────────────────────────────────────────────────────────
99
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
+
100
115
  function summarizeArgs(args) {
101
116
  try {
102
117
  const s = JSON.stringify(args ?? {});
@@ -177,6 +192,21 @@ const pendingInjectTimers = new Set();
177
192
  function maybeInject(ctx, sessionId, violation) {
178
193
  if (!pluginConfig.correctInject) return;
179
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
+ }
180
210
  // v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
181
211
  // 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
182
212
  // 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
@@ -232,7 +262,9 @@ function maybeInject(ctx, sessionId, violation) {
232
262
  });
233
263
  audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入已投递(agent=${sessionId}):${String(violation.reason || "").slice(0, 120)}`, session: sessionId });
234
264
  } else {
235
- 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 });
236
268
  }
237
269
  } catch (error) {
238
270
  audit({ kind: "inject", rule: violation.ruleId, name: "纠正注入", event: "inject", reason: `注入异常:${error instanceof Error ? error.message : String(error)}`, session: sessionId });
@@ -248,35 +280,67 @@ function maybeInject(ctx, sessionId, violation) {
248
280
  * judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
249
281
  */
250
282
  async function deliverSuspects(ctx, sessionId, suspects, text) {
283
+ // 0.5.11(用户定稿,预算打穿修复):一次回复的全部嫌疑 = 一次批量裁决(一次 LLM/一条预算)
284
+ // 此前逐条 judgeViolation(每条一次调用/一条预算)导致 50/日早上耗尽(unavailable 89 条实证)。
251
285
  const judgeFn = state.judgeFn || judgeViolation;
252
- const results = await Promise.all(suspects.map(async (v) => {
253
- try {
254
- const verdict = await judgeFn(ctx, state, sessionId, v, text);
255
- const kind = verdict.action === "deliver" ? "judge-pass"
256
- : verdict.action === "suppress" ? "judge-false"
257
- : "judge-unavailable";
258
- audit({
259
- kind,
260
- rule: v.ruleId,
261
- name: "违规裁决",
262
- event: "assistant/message",
263
- reason: `${verdict.action}:${verdict.note || ""}${verdict.model ? `(model=${verdict.model})` : ""}`,
264
- session: sessionId
265
- });
266
- return verdict.action === "deliver" ? v : null;
267
- } catch (error) {
268
- audit({
269
- kind: "judge-unavailable",
270
- rule: v.ruleId,
271
- name: "裁决异常",
272
- event: "assistant/message",
273
- reason: `未投递:${error instanceof Error ? error.message : String(error)}`,
274
- 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 明细应对`
275
321
  });
276
- return null;
322
+ } else if (deliver.length === 1) {
323
+ maybeInject(ctx, sessionId, deliver[0]);
277
324
  }
278
- }));
279
- 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
+ }
280
344
  if (deliver.length > 1) {
281
345
  const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
282
346
  maybeInject(ctx, sessionId, {
@@ -352,9 +416,35 @@ export function handleSessionEvent(ctx, session, event) {
352
416
  });
353
417
  maybeInject(ctx, sid, {
354
418
  ruleId: "__engram-gap",
355
- reason: "规则 19/M8:手册/AGENTS 落盘后必须在同一回合补 engram_store,否则记忆机制断链——请立即补写并说明"
419
+ reason: "规则 19/M8:手册/AGENTS 落盘后应在同一回合补 engram_store,否则记忆机制断链(已记审计)"
356
420
  });
357
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
+ }
358
448
  return;
359
449
  }
360
450
  if (event.type === "user/message") {
@@ -422,8 +512,10 @@ export function handleSessionEvent(ctx, session, event) {
422
512
  // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
423
513
  s.turn.scopes = scopesFromIntents(s.turn.intents);
424
514
  // LLM 意图兜底(方案 A):低置信/歧义消息异步预取,不阻塞事件流;失败自动降级词表
515
+ // A2(阶段一):promise 记录到 turn——pre-execute 拦截点可同步等待,防"预取未回就按词表拦"竞态
425
516
  if (pluginConfig.llmIntent?.enabled) {
426
- void enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
517
+ const p = enrichIntentWithLlm(ctx, state, sid, text, pluginConfig.llmIntent);
518
+ s.turn.llmIntentPromise = p;
427
519
  }
428
520
  if (state.taskContract?.taskContractEnabled) {
429
521
  const patch = naturalMode(text, s.contract);
@@ -576,6 +668,33 @@ export function handleSessionEvent(ctx, session, event) {
576
668
  }
577
669
  }
578
670
 
671
+ // 0.5.10 建议5(已知坑错误码召回):错误结果文本命中特征表 → 审计 + 注入指向知识库的提示
672
+ //(本轮去重;仍走 maybeInject 资格链——真实用户在场/预算内才投递,不违反注入噪音治理)
673
+ if (isError) {
674
+ const errText = String(
675
+ resultBlock?.content?.map?.((c) => c?.text || "").join(" ") ||
676
+ d?.message?.content?.map?.((c) => c?.text || "").join(" ") ||
677
+ ""
678
+ );
679
+ const pit = matchKnownPitfall(errText);
680
+ if (pit) {
681
+ if (!s.turn.errorHints) s.turn.errorHints = new Set();
682
+ if (!s.turn.errorHints.has(pit.key)) {
683
+ s.turn.errorHints.add(pit.key);
684
+ audit({
685
+ kind: "error-hint",
686
+ rule: "__error-hint",
687
+ name: "已知坑召回",
688
+ event: "tool/result",
689
+ tool: pendingCall?.name || undefined,
690
+ reason: `${pit.key} 命中特征表(建议5:错误码→知识库)`,
691
+ session: sid
692
+ });
693
+ maybeInject(ctx, sid, { ruleId: "__error-hint", reason: pit.hint });
694
+ }
695
+ }
696
+ }
697
+
579
698
  // M8 双通道机制(2026-08-24):dsh-manual-write 落盘成功 → 标记;同轮 engram_store 成功 → 标记
580
699
  if (!isError && pendingCall) {
581
700
  const cmd = pendingCall.args?.command || pendingCall.args?.code || "";
@@ -609,17 +728,30 @@ export function handleSessionEvent(ctx, session, event) {
609
728
  const auditCmd = pendingCall.args?.command || pendingCall.args?.code || "";
610
729
  if (isAuditCommand(auditCmd)) {
611
730
  const output = extractToolOutput(d);
612
- if (isError || auditOutputFailed(output)) {
731
+ if (auditOutputFailed(output)) {
613
732
  audit({
614
733
  kind: "mount-audit-fail",
615
734
  rule: "27",
616
735
  name: "全量审计未通过",
617
736
  event: "tool/result",
618
- reason: isError ? "审计脚本执行失败" : "审计脚本发现 DUPLICATES FOUND",
737
+ reason: "审计脚本发现 DUPLICATES FOUND",
619
738
  session: sid,
620
739
  tool: pendingCall.name
621
740
  });
622
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
+ });
623
755
  } else if (auditOutputPassed(output)) {
624
756
  s.mountAuditRevision = state.mountRevision;
625
757
  s.mountAuditSignature = computeMountSignature(profileNameFromArgs(pendingCall.args));
@@ -641,11 +773,15 @@ export function handleSessionEvent(ctx, session, event) {
641
773
  if (!isError && pendingCall && pendingCall.originalContent != null && versionTarget && isVersionedFile(versionTarget)) {
642
774
  try {
643
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;
644
779
  const check = validateEditedFile(
645
780
  pendingCall.originalContent,
646
781
  current,
647
- pendingCall.args.old_string || "",
648
- pendingCall.args.new_string || ""
782
+ oldStr,
783
+ pendingCall.args.new_string || pendingCall.args.new_str || "",
784
+ uniqueMatch
649
785
  );
650
786
  if (!check.ok) {
651
787
  writeFileSync(versionTarget, pendingCall.originalContent, "utf8");
@@ -717,10 +853,10 @@ export function handleSessionEvent(ctx, session, event) {
717
853
  // 避免“仅会话内说明/不跨会话保留”等选项说明把一次小授权放大成全局 12h(2026-08-24 修复)
718
854
  const scopeText = `${askQuestionCoreText(pending.questions)} ${selectedText}`.trim();
719
855
  const pathPrefix = inferPathPrefixFromText(scopeText || qText);
720
- // ask 问题文本措辞不可靠,授权记录为宽泛类型 any + 路径前缀,避免类型错位
721
- // 无路径的全局 any 授权缩短 TTL,降低安全边界风险
856
+ // 0.5.10 建议1①:ask 答复结构化——操作类型从答复文本推断(write/command/any),不再一律 any
857
+ // (修复"弹窗答复接不住真想操作"的粒度问题——V476AL 同族)
722
858
  const authRecord = {
723
- type: "any",
859
+ type: classifyAskScopeType(scopeText || qText),
724
860
  pathPrefix,
725
861
  source: "ask"
726
862
  };
@@ -762,8 +898,11 @@ export function handleSessionEvent(ctx, session, event) {
762
898
  reason: `记录授权范围:${qText.slice(0, 120)}`,
763
899
  session: sid
764
900
  });
765
- } else {
766
- // 弹窗消减(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 授权证据把关(不降级)。
767
906
  s.turn.askRejected = true;
768
907
  if (!state.askRejections) state.askRejections = [];
769
908
  state.askRejections.push({ sessionId: sid, at: Date.now() });
@@ -773,7 +912,17 @@ export function handleSessionEvent(ctx, session, event) {
773
912
  rule: "12D",
774
913
  name: "ask_user_question 未授权",
775
914
  event: "tool/result",
776
- 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 无结果/未响应(用户可能未及回复)——不计入拒绝节流池;后续可再次询问",
777
926
  session: sid
778
927
  });
779
928
  }
@@ -794,7 +943,9 @@ export function handleSessionEvent(ctx, session, event) {
794
943
  // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
795
944
  if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
796
945
  // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
797
- if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
946
+ // F2(2026-08-28 阶段三):词面收紧——"完成"裸词太宽("完成社区检索/尚未完成/正在完成"均误触),
947
+ // 改为强完成声明模式且排除否定/进行态;仍由 LLM 裁决层兜底(deliverSuspects)。
948
+ if (DELIVERY_RE.test(text)) {
798
949
  const windowStart = Date.now() - 30 * 60 * 1000;
799
950
  const hasPass = (state.verifyPass || []).some((v) => v.sessionId === sid && v.at > windowStart);
800
951
  if (!hasPass) {
@@ -847,7 +998,13 @@ export function handleSessionEvent(ctx, session, event) {
847
998
  });
848
999
  }
849
1000
  }
850
- 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
+ }
851
1008
  for (const v of violations) {
852
1009
  audit({
853
1010
  kind: v.kind,
@@ -1046,7 +1203,7 @@ async function executeGuard(ctx, invocation) {
1046
1203
  continue;
1047
1204
  }
1048
1205
  const t = (e.ts || "").replace("T", " ").slice(0, 19);
1049
- 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})` : ""}`);
1050
1207
  if (e.reason) parts.push(` 原因:${e.reason}`);
1051
1208
  if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
1052
1209
  }
@@ -1125,11 +1282,17 @@ async function executeGuard(ctx, invocation) {
1125
1282
  }
1126
1283
  case "label": {
1127
1284
  const entries = readAuditLog(500);
1128
- const found = entries.find((e) => e.eventId === command.eventId);
1129
- if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}` };
1130
- state.labels.set(command.eventId, command.label);
1131
- audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${command.eventId} = ${command.label}`, session: invocation?.session?.id || "global" });
1132
- 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}` };
1133
1296
  }
1134
1297
  default:
1135
1298
  return { kind: "error", text: USAGE };
@@ -1281,6 +1444,7 @@ export function apply(ctx) {
1281
1444
  tool: exec?.name,
1282
1445
  args: summarizeArgs(exec?.arguments),
1283
1446
  reason: hit.reason,
1447
+ errId: hit.errId,
1284
1448
  session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
1285
1449
  });
1286
1450
  state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
@@ -1293,10 +1457,12 @@ export function apply(ctx) {
1293
1457
  if (oldest !== undefined) state.deniedKeys.delete(oldest);
1294
1458
  }
1295
1459
  // 弹窗消减(2026-08-24):E3/ask 节流命中后注入纠正,提示改用普通文本,勿连环弹窗
1460
+ // D1(2026-08-28 阶段三):改**陈述式**——旧文案"请直接执行"是命令,模型照做会越过
1461
+ // 用户(实弹:注入后模型跳过用户回复直接执行);只陈述事实,决策权留给用户。
1296
1462
  if (hit.ruleId === "__already-authorized" || hit.ruleId === "__ask-rejected" || hit.ruleId === "__ask-throttle") {
1297
1463
  maybeInject(ctx, sessionIdOfExec(exec), {
1298
1464
  ruleId: hit.ruleId,
1299
- reason: `${hit.title}:已有授权或询问被拒时请直接执行、或用普通文本说明,不要再弹窗 ask。`
1465
+ reason: `${hit.title}(规则 ${hit.ruleId}):本会话已存在相近授权记录或被拒记录;再次弹窗询问可能无法送达用户。可用普通文本说明。`
1300
1466
  });
1301
1467
  }
1302
1468
  return hit.reason;
@@ -1308,6 +1474,25 @@ export function apply(ctx) {
1308
1474
  );
1309
1475
 
1310
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
+ });
1311
1496
  ctx.on("tools/pre-execute", async (exec, next) => {
1312
1497
  try {
1313
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.9",
3
+ "version": "0.5.11",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",