dsh-rule-engine 0.5.5 → 0.5.6

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.
@@ -177,6 +177,9 @@ function taskContractGuardDecision(state, exec, session) {
177
177
  if (!isArmed(contract, state.taskContract)) return null;
178
178
  const action = classifyAction(exec?.name, exec?.arguments);
179
179
  const dec = decideContractAction({ contract, action, config: state.taskContract });
180
+ // 2026-08-25 预算双判防护:AGENT_BUDGET_EXHAUSTED 唯一裁决点 = tools/pre-execute 钩子(扣减前判定);
181
+ // guard 层可能在 pre-execute 扣减之后运行(收到已扣 contract),若在此重判会误拒“预算内”调用(允许 N 实际 N-1)。
182
+ if (dec.reasonCode === "AGENT_BUDGET_EXHAUSTED") return null;
180
183
  if (dec.outcome === "deny") {
181
184
  return makeHit(
182
185
  { ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
@@ -350,6 +353,9 @@ function matchRule(cfg, ctx) {
350
353
  if (isReadOnlyTool(name, args)) return null;
351
354
  if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
352
355
  const op = operationOf(name, args);
356
+ // 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
357
+ // 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
358
+ if (op.type === "backup") return null;
353
359
  // 只使用本回合的 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes);
354
360
  // 不再把 session.authorizations 里历史 ask 授权拉进来,避免上一轮授权放宽本轮。
355
361
  const scopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
@@ -25,15 +25,15 @@ export const TAG_RE = /【(执行|方案|问询|信息)】/;
25
25
  export const QUESTION_RE = /[??]|吗|为什么|是否|能不能|行不行|难道|是不是|能否|怎么|为何|是否要|影响吗|可以吗|要不要|需不需要/;
26
26
 
27
27
  const DEFER_RE = /待确认|确认后再|等我确认|先方案|先别|不要执行|之后再说|确认后|再确认|先确认|不要直接/;
28
- const ACTION_RE = /执行|落盘|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|安装|发布|下载|提交|运行|修复|替换|重建|启动|重启|停止|卸载|开始改|开始写|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|继续|推进|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|开工|动手|进行|完成|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究/;
28
+ const ACTION_RE = /执行|落盘|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|安装|发布|下载|提交|运行|修复|替换|重建|启动|重启|停止|卸载|开始改|开始写|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|继续|推进|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|开工|动手|进行|完成|选择|采纳|选定|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究/;
29
29
  const PLAN_RE = /方案|建议|评估|分析|设计|规划|计划|给出|提供/;
30
- const STRONG_EXEC_RE = /执行吧|开始执行|直接执行|立即执行|现在执行|马上执行|请落盘|开始写|开始改|执行以下|执行第|执行这个|执行它|落盘|开始蒸馏|开始迁移|开始修复|开始改进|开始优化/;
30
+ const STRONG_EXEC_RE = /执行吧|开始执行|直接执行|立即执行|现在执行|马上执行|请落盘|开始写|开始改|执行以下|执行第|执行这个|执行它|落盘|开始蒸馏|开始迁移|开始修复|开始改进|开始优化|采纳|选定/;
31
31
  const NUMBERED_ITEM_RE = /^\s*(?:\(?(\d+)[\)、..)]\s*)/;
32
32
 
33
33
  // 状态信号:对先前指令的就绪/完成确认(“我已重启 / 已输入 / 重启完成 / 好了 / done”)。
34
34
  // 语义 = 与“无用户消息回合”同等待遇:不触发规则 22 的“无执行分点”判定(规则 22⑩)。
35
35
  export const STATUS_SIGNAL_RE =
36
- /(?:我已|我已经|我(?:们)?(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好))(?:了|完毕|完成|好)?|(?:^|\s)(?:已|已经)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)(?:了|完毕|完成|好)?(?:\s|$)|(?:^|\s)(?:重启|输入|命令|操作)(?:完成|完毕|好了|已输入)(?:\s|$)|(?:^|\s)好了(?:\s|$)|^(?:done|restarted|finished|entered)$/i;
36
+ /(?:我已|我已经|我(?:们)?(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好))(?:了|完毕|完成|好)?|(?:^|\s)(?:已|已经)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)(?:了|完毕|完成|好)?(?:\s|$)|(?:^|\s)已经(?:刚)?(?:重启|输入|执行|完成|搞定|做完|弄好|输完|输好|unlock)[^\s]*(?:\s|$)|(?:^|\s)(?:重启|输入|命令|操作)(?:完成|完毕|好了|已输入)(?:\s|$)|(?:^|\s)好了(?:\s|$)|^(?:done|restarted|finished|entered)$/i;
37
37
 
38
38
  // 危险动作词:命中则不当状态信号(防“我已删除了某文件”被当成就绪确认)
39
39
  const DANGEROUS_ACTION_RE = /删除|覆盖|移除|清空|卸载|remove-item|rm\s+-r|del\s+/i;
package/lib/core/state.js CHANGED
@@ -112,6 +112,7 @@ export function getSessionState(state, sessionId) {
112
112
  if (!s) {
113
113
  s = {
114
114
  id: key,
115
+ ruleStats: {}, // C2:每规则 detected/suppressed/injected 统计(会话级,跨回合累计)
115
116
  manualReadSeen: false,
116
117
  lastUserText: "",
117
118
  selfCertCount: new Map(),
@@ -51,6 +51,20 @@ function isMostlyEnglish(text) {
51
51
  return letters > 30 && cjk === 0;
52
52
  }
53
53
 
54
+ /**
55
+ * 注入噪音治理 v0.5.6(建议2/3 已自证抑制):同一条回复中已含"规则 X 已按/已自证/已核对…"标记时,
56
+ * 该规则本轮不再计为新违规——打断"自证回复复述触发词 → 再次注入"的自循环。
57
+ * 仅对 kind=self-certify 的 D 级自证命中生效;B 级纠察(kind=correct)不受影响(审计优先)。
58
+ */
59
+ export function isSelfCertified(text, ruleId) {
60
+ if (!text || !ruleId) return false;
61
+ const id = String(ruleId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
62
+ const tail = "(?:已按|已按要求|已自证|已核对|已核实|核实通过|合规|已满足|已一次性说明|已合并提出|已回应|已实施|已落地)";
63
+ const suffix = "(?:[①②③④⑤⑥⑦⑧⑨⑩]|\\.\\d+|\\d+)?";
64
+ const re = new RegExp(`(?:规则\\s*${id}${suffix}\\s*${tail}|${tail}\\s*规则\\s*${id}${suffix})`);
65
+ return re.test(text);
66
+ }
67
+
54
68
  /**
55
69
  * 检测一次 assistant/message 的 B/D 级违规。
56
70
  * @param {object} options
@@ -223,5 +237,21 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
223
237
  });
224
238
  }
225
239
 
226
- return hits;
240
+ // C2 统计:命中(detected)计数——在抑制过滤前逐条累计
241
+ for (const h of hits) {
242
+ const st = (session.ruleStats ||= {});
243
+ const k = (st[h.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
244
+ k.detected++;
245
+ }
246
+ // 注入噪音治理 v0.5.6(建议2/3):已自证标记("规则 X 已按/已自证/已核对…")→ 该规则本轮不计新违规
247
+ const kept = hits.filter((h) => h.kind !== "self-certify" || !isSelfCertified(text, h.ruleId));
248
+ // C2 统计:被抑制(suppressed)计数
249
+ for (const h of hits) {
250
+ if (!kept.includes(h)) {
251
+ const st = session.ruleStats || {};
252
+ const k = st[h.ruleId];
253
+ if (k) k.suppressed++;
254
+ }
255
+ }
256
+ return kept;
227
257
  }
package/lib/index.js CHANGED
@@ -176,6 +176,13 @@ function maybeInject(ctx, sessionId, violation) {
176
176
  const count = state.injectCounts.get(key) || 0;
177
177
  if (count >= pluginConfig.injectLimitPerRulePerSession) return;
178
178
  state.injectCounts.set(key, count + 1);
179
+ // C2 统计:注入(injected)计数(实际投递尝试,上限内)
180
+ {
181
+ const sStat = getSessionState(state, sessionId);
182
+ const st = (sStat.ruleStats ||= {});
183
+ const k = (st[violation.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
184
+ k.injected++;
185
+ }
179
186
  const timer = setTimeout(() => {
180
187
  pendingInjectTimers.delete(timer);
181
188
  try {
@@ -720,7 +727,19 @@ export function handleSessionEvent(ctx, session, event) {
720
727
  reason: v.reason,
721
728
  session: sid
722
729
  });
723
- maybeInject(ctx, sid, v);
730
+ }
731
+ // 注入噪音治理 v0.5.6(建议1/B1 聚合注入):同回合多条违规 → 聚合为一条注入(明细全在 /guard log);
732
+ // 单条仍按逐规则投递(保留完整提示文本与逐规则计数上限);聚合注入用伪规则 __self-cert 计数。
733
+ if (violations.length > 1) {
734
+ const briefs = violations
735
+ .map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
736
+ .join(";");
737
+ maybeInject(ctx, sid, {
738
+ ruleId: "__self-cert",
739
+ reason: `本轮检出 ${violations.length} 项待自证:${briefs}——请按 /guard log 明细一次性自证`
740
+ });
741
+ } else if (violations.length === 1) {
742
+ maybeInject(ctx, sid, violations[0]);
724
743
  }
725
744
  if (violations.length) {
726
745
  state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
@@ -1127,15 +1146,32 @@ export function apply(ctx) {
1127
1146
  }
1128
1147
  });
1129
1148
 
1130
- // 1.1 任务契约 ask 通道:仅在总开关 + askEnabled 时对“应询问”动作返回 ask
1149
+ // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1150
+ // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1151
+ // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1152
+ // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
1131
1153
  ctx.on("tools/pre-execute", async (exec, next) => {
1132
1154
  try {
1133
- if (!state.taskContract?.taskContractEnabled || !state.taskContract?.askEnabled) return next();
1155
+ if (!state.taskContract?.taskContractEnabled) return next();
1134
1156
  const sid = sessionIdOfExec(exec);
1135
1157
  const s = getSessionState(state, sid);
1136
1158
  if (!isArmed(s.contract, state.taskContract)) return next();
1137
1159
  const action = classifyAction(exec?.name, exec?.arguments);
1138
1160
  const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1161
+ // deny(在扣减前判定,语义=本次调用发生时点)
1162
+ if (dec.outcome === "deny") {
1163
+ audit({
1164
+ kind: "task-contract-deny",
1165
+ rule: "__task-contract",
1166
+ name: `任务契约拒绝:${dec.reasonCode}`,
1167
+ event: "tools/pre-execute",
1168
+ tool: exec?.name,
1169
+ args: summarizeArgs(exec?.arguments),
1170
+ reason: dec.reason,
1171
+ session: sid
1172
+ });
1173
+ return { kind: "deny", reason: `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "请调整契约"})` };
1174
+ }
1139
1175
  // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1140
1176
  if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1141
1177
  s.contract = consumeDelegateBudget(s.contract, action.delegationCount);
package/lib/service.js CHANGED
@@ -16,7 +16,8 @@ const REMOTE_METHODS = [
16
16
  "getAuditLog",
17
17
  "getUnderstanding",
18
18
  "getTaskContractConfig",
19
- "setTaskContractConfig"
19
+ "setTaskContractConfig",
20
+ "getRuleStats"
20
21
  ];
21
22
 
22
23
  function currentVersion() {
@@ -210,6 +211,39 @@ class RuleEngineService extends TypertRemoteService {
210
211
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
211
212
  }
212
213
  }
214
+
215
+ /** C2 轻量版:规则统计(detected/suppressed/injected,会话聚合;只读) */
216
+ async getRuleStats() {
217
+ try {
218
+ const rows = aggregateRuleStats(state.sessions.values());
219
+ return {
220
+ ok: true,
221
+ stats: rows,
222
+ totals: rows.reduce((t, r) => ({
223
+ detected: t.detected + r.detected,
224
+ suppressed: t.suppressed + r.suppressed,
225
+ injected: t.injected + r.injected
226
+ }), { detected: 0, suppressed: 0, injected: 0 }),
227
+ sessions: state.sessions.size
228
+ };
229
+ } catch (error) {
230
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
231
+ }
232
+ }
233
+ }
234
+
235
+ /** 纯函数:聚合各会话 ruleStats(C2,可独立测试) */
236
+ export function aggregateRuleStats(sessions) {
237
+ const merged = {};
238
+ for (const s of sessions) {
239
+ for (const [ruleId, st] of Object.entries(s.ruleStats || {})) {
240
+ const m = (merged[ruleId] ||= { ruleId, detected: 0, suppressed: 0, injected: 0 });
241
+ m.detected += st.detected || 0;
242
+ m.suppressed += st.suppressed || 0;
243
+ m.injected += st.injected || 0;
244
+ }
245
+ }
246
+ return Object.values(merged).sort((a, b) => b.detected - a.detected);
213
247
  }
214
248
 
215
249
  export { RuleEngineService, RuleEngineService as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",