dsh-rule-engine 0.5.5 → 0.5.7

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.
package/README.md CHANGED
@@ -1,7 +1,12 @@
1
1
  # dsh-rule-engine
2
2
 
3
+ ![npm](https://img.shields.io/npm/v/dsh-rule-engine)
4
+ ![version](https://img.shields.io/badge/version-0.5.7-blue)
5
+
3
6
  DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
4
7
 
8
+ > 当前版本 **0.5.7**(2026-08-26 发布)。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
9
+
5
10
  ## 项目背景
6
11
 
7
12
  这个项目来自一个非常具体的个人需求:
@@ -22,6 +27,25 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
22
27
 
23
28
  当前实现以「模式库兜底」为主,LLM 理解器预留扩展点;所有规则均从 AGENTS.md 实时解析。
24
29
 
30
+ ## 注入噪音治理(0.5.6 / 0.5.7)
31
+
32
+ 只提醒真正值得提醒的事——这条原则贯穿 0.5.6 与 0.5.7:
33
+
34
+ - **0.5.6**:同一回复中多条违规 → 聚合为一条注入(明细全在 `/guard log`);回复含「规则 X 已自证/已核对…」标记 → 该规则当轮不再重复触发;C2 规则统计(detected/suppressed/injected,`getRuleStats` 面板接口)。
35
+ - **0.5.7(错误才值得被提醒)**:
36
+ 1. **词表只产嫌疑**:语义型命中不再直接定罪,标记 `awaitingJudge` 送裁决;
37
+ 2. **LLM 裁决**:给「规则正文 + 完整回复」判定是否真违规——合规声明/引述/非违规 → 不投递(`judge-false`);仅裁决为违规才投递(`judge-pass`);裁决不可用 → 不投递(**fail-closed**,`judge-unavailable`)。模型**追随会话模型**,`sha256` 缓存 + 每日 50 次/会话预算;
38
+ 3. **注入轮不检测**:没有真实用户消息的回合(引擎注入触发的轮次)不做检测与投递——这是"引擎自己打乒乓"循环的根治(燃料=它对自身回声的检测);
39
+ 4. **投递资格闸**:同规则同会话仅提醒一次(记住已处理);会话每小时至多 3 条弹窗;
40
+ 5. **审计完整性**:嫌疑/裁决/拦截/投递全部写入 `/guard log`——"看了不冤枉"的凭据。
41
+
42
+ ## 质量与验证(2026-08-26,对齐官方 docs/testing.zh.md)
43
+
44
+ - `npm run test`(全量单测;`test/run-all.mjs` 统一入口,注意 ESM 缓存顺序约定);
45
+ - `node scripts/verify-all.mjs` —— 交付前四层体检:语法(lib 全文件 `node --check`)→ 单元(run-all)→ **组合冒烟**(`test/loader-smoke.e2e.mjs`:真实引擎代码 + 真实审计文件,仅 mock LLM 边界,断言**外部世界**——审计文件里真的出现 judge-false/judge-pass 记录,而非自我报告)→ **真实判例**(近 24h 台账 judge-pass/false 记录数,0 条 = WARN 提示需实弹);
46
+ - `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
47
+ - 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。
48
+
25
49
  ## 任务契约与反过度工程(可选)
26
50
 
27
51
  - 默认**关闭**;可在规则引擎设置页开启「任务边界与反过度工程」总开关。
@@ -88,6 +112,7 @@ dsh plugin --profile web add dsh-rule-engine
88
112
  - 技能目录实时联动:`ctx.skills` 目录变化后自动刷新,已禁用/不存在的技能不触发 12B
89
113
  - LLM 增量理解:对非 high 置信规则调用 `ctx.llm` 补全结构化理解,失败自动回退模式库;AGENTS.md 变化触发重载后会自动补一次增量理解(按规则+版本去重,不重复烧 token)
90
114
  - D 级自证泛化:按规则特征触发自证提示,每规则每会话限 3 次
115
+ - D 级自证泛化(0.5.7 起取代上行语义):词表只产嫌疑,LLM 裁决确认错误才提醒;同规则同会话仅提醒一次 + 会话每小时 3 条弹窗预算;无真实用户消息的回合不检测不投递(乒乓根治)
91
116
  - 授权记录默认 10 分钟 TTL,无路径的全局授权 TTL 缩短为 2 分钟;可用 `/guard revoke` 撤销
92
117
  - 用户直接命令式指令(如“删除这个文件”)也视为授权
93
118
  - 规则 1 支持“用户明确要求重试”豁免
@@ -116,6 +141,7 @@ dsh plugin --profile web add dsh-rule-engine
116
141
 
117
142
  5. **跨会话持久化**
118
143
  授权/备份目前为内存态,重启失效。持久化涉及写入保护、并发与恢复,风险较高,暂未实现。
144
+ 注(0.5.7):验证通过记录 verifyPass 已持久化(`~/.dsh/rule-engine-verify.json`,热重载/重启不丢)——规则 23④ 证据链;授权/备份仍为内存态。
119
145
 
120
146
  6. **输出文本实时拦截**
121
147
  受 DSH 官方架构限制,`assistant/message` 无法“拦下不发”,只能事后审计 + 纠正注入;这是平台边界,不是插件能单独突破的。
@@ -147,6 +173,10 @@ npm test
147
173
  bash scripts/build.sh
148
174
  ```
149
175
 
176
+ 交付前体检(0.5.7 起):`node scripts/verify-all.mjs`(四层:语法/单元/组合冒烟/真实判例)与 `node scripts/health-audit.mjs`(找茬)——详见「质量与验证」。
177
+
178
+ 发布:`node ../../scripts/release-plugin.mjs <插件目录> <版本号>`(一键 npm + git + GitHub Release,见 `scripts/release-plugin.mjs` 注释)。
179
+
150
180
  ## License
151
181
 
152
182
  MIT
@@ -20,6 +20,7 @@ import {
20
20
  isReadOnlyTool,
21
21
  isSensitiveToolCall,
22
22
  isVariablePath,
23
+ isVerificationCommand,
23
24
  pathTarget
24
25
  } from "./patterns.js";
25
26
  import { authMatches, describeAuth, describeOp, describeScopes, findMatchingAuth, operationOf, askQuestionText, inferPathPrefixFromText, inferTypeFromText, scopesFromIntents } from "./authorization.js";
@@ -177,6 +178,9 @@ function taskContractGuardDecision(state, exec, session) {
177
178
  if (!isArmed(contract, state.taskContract)) return null;
178
179
  const action = classifyAction(exec?.name, exec?.arguments);
179
180
  const dec = decideContractAction({ contract, action, config: state.taskContract });
181
+ // 2026-08-25 预算双判防护:AGENT_BUDGET_EXHAUSTED 唯一裁决点 = tools/pre-execute 钩子(扣减前判定);
182
+ // guard 层可能在 pre-execute 扣减之后运行(收到已扣 contract),若在此重判会误拒“预算内”调用(允许 N 实际 N-1)。
183
+ if (dec.reasonCode === "AGENT_BUDGET_EXHAUSTED") return null;
180
184
  if (dec.outcome === "deny") {
181
185
  return makeHit(
182
186
  { ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
@@ -350,6 +354,21 @@ function matchRule(cfg, ctx) {
350
354
  if (isReadOnlyTool(name, args)) return null;
351
355
  if (!COVERED_MUTATION_TOOLS.has(name) && !looksLikeFileMutation(name, args)) return null;
352
356
  const op = operationOf(name, args);
357
+ // v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令伴生放行——本回合已有 write/any/command
358
+ // 授权(用户已批"修改/执行")时,运行测试/冷加载/审计/语法检查属于该变更的验证闭环,
359
+ // 不再被 write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
360
+ if ((name === "pwsh" || name === "bash") && isVerificationCommand(cmd)) {
361
+ const verScopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
362
+ ? session.turn.scopes
363
+ : scopesFromIntents(turnIntents);
364
+ if (verScopes.some((s) => s.type === "write" || s.type === "any" || s.type === "command")) {
365
+ audit?.({ kind: "allow", rule: cfg.ruleId, name: "验证命令伴生放行", tool: name, reason: `本回合已有 ${describeScopes(verScopes)} 授权 → 验证命令伴生(${describeOp(op)})`, session: sessionIdOf(exec) });
366
+ return null;
367
+ }
368
+ }
369
+ // 保护性备份豁免(2026-08-26,backup 口径修复):规则 12A 豁免③ / 13A ⑦ 均豁免
370
+ // 复制到 .backups/.bak 的保护性备份——与 13A 分支口径一致,backup 类型不再被 22 粒度误拦。
371
+ if (op.type === "backup") return null;
353
372
  // 只使用本回合的 scopes(execute 子句 + 本回合 ask 授权已由 handleSessionEvent 写入 turn.scopes);
354
373
  // 不再把 session.authorizations 里历史 ask 授权拉进来,避免上一轮授权放宽本轮。
355
374
  const scopes = Array.isArray(session.turn.scopes) && session.turn.scopes.length > 0
@@ -405,7 +424,7 @@ function matchRule(cfg, ctx) {
405
424
  // 统一入口命令豁免:dsh-manual-write.mjs 每次写入前自身执行备份(backup() 保留 5 份),
406
425
  // 引擎静态扫描看不到脚本内部动作(已知盲区);入口命令也已被 __self-protect 限定为唯一写通道。
407
426
  if ((name === "pwsh" || name === "bash") && isEntryChannelCommand(cmd)) return null;
408
- const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args) && !/git\s+(push|commit)/i.test(cmd)));
427
+ const destructive = (name === "pwsh" || name === "bash") && cmd && (DESTRUCTIVE_CMD.test(cmd) || (isSensitiveToolCall(name, args, sessionIdOf(exec)) && !/git\s+(push|commit)/i.test(cmd)));
409
428
  const highRiskWrite = (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) && isProtectedConfigPath(p);
410
429
  if (highRiskWrite && unlock && !isHighRiskEntryFile(p)) return null;
411
430
  if (destructive || highRiskWrite) {
@@ -480,7 +499,7 @@ function matchRule(cfg, ctx) {
480
499
 
481
500
  // 规则 12A:敏感操作需要匹配授权证据
482
501
  if (cfg.handler === "rule12a-approval") {
483
- if (isSensitiveToolCall(name, args)) {
502
+ if (isSensitiveToolCall(name, args, sessionIdOf(exec))) {
484
503
  // 规则 19:dsh-usage-manual/SKILL.md 正文更新免逐次确认(仅手册本身)
485
504
  if (p && MANUAL_PATH_RE.test(p)) return null;
486
505
  // /guard unlock 本身即用户对受保护配置的授权
@@ -571,7 +590,7 @@ function matchRule(cfg, ctx) {
571
590
  if (hints.includes("manual") && session.turn.toolCount === 0 && !session.manualReadSeen && !isManualReadTool(name, args)) {
572
591
  return makeHit(cfg, `【硬拦截】${cfg.title}`);
573
592
  }
574
- if (hints.includes("sensitive") && isSensitiveToolCall(name, args)) {
593
+ if (hints.includes("sensitive") && isSensitiveToolCall(name, args, sessionIdOf(exec))) {
575
594
  if (p && MANUAL_PATH_RE.test(p)) return null;
576
595
  if (unlock && isProtectedConfigPath(p)) return null;
577
596
  const op = operationOf(name, args);
@@ -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;
@@ -0,0 +1,85 @@
1
+ // judge.js - D 级嫌疑裁决器(v0.5.7,用户拍板 2026-08-26)。
2
+ // 原则:词表命中只是"嫌疑";"是否是错误"由 LLM 确认——只有裁决为违规的才进入投递。
3
+ // 模型:追随会话模型(复用 llm-understander.resolveRoute,与现有 LLM 意图判定同一路由机制,
4
+ // 用户拍板:不单独固定模型,避免独立路由稳定性问题)。
5
+ // 保护:sha256(ruleId+text) 缓存(同文本不重复调用);每会话每日 JUDGE_DAILY_LIMIT 次预算;
6
+ // 调用失败/超时/无路由 → 返回 unavailable(fail-closed:不投递,只审计)。
7
+ import { createHash } from "node:crypto";
8
+ import { resolveRoute, parseJsonOutput } from "./llm-understander.js";
9
+
10
+ /** 每日裁决预算(用户拍板:50 次/会话/天;超预算 → unavailable,只审计不投递)。 */
11
+ export const JUDGE_DAILY_LIMIT = 50;
12
+
13
+ export function judgeCacheKey(ruleId, text) {
14
+ return createHash("sha256").update(`${ruleId}\n${text}`).digest("hex");
15
+ }
16
+
17
+ export function judgeBudgetKey(sessionId, now = Date.now()) {
18
+ const d = new Date(now);
19
+ return `${sessionId}:${d.getUTCFullYear()}-${d.getUTCMonth() + 1}-${d.getUTCDate()}`;
20
+ }
21
+
22
+ /**
23
+ * 裁决一次嫌疑。
24
+ * @returns {Promise<{action:"deliver"|"suppress"|"unavailable", note:string, model?:string}>}
25
+ * deliver = 确认真违规 → 允许投递
26
+ * suppress = 确认合规声明/引述/非违规 → 不投递(审计 judge-false)
27
+ * unavailable = 无法裁决(预算满/无路由/调用失败)→ 不投递(fail-closed,审计 judge-unavailable)
28
+ */
29
+ export async function judgeViolation(ctx, state, sessionId, violation, text) {
30
+ const key = judgeCacheKey(violation.ruleId, text);
31
+ const cached = state.judgeCache?.get(key);
32
+ if (cached) return cached;
33
+
34
+ const bKey = judgeBudgetKey(sessionId);
35
+ const budget = state.judgeBudget?.get(bKey) || { count: 0 };
36
+ if (budget.count >= JUDGE_DAILY_LIMIT) {
37
+ return { action: "unavailable", note: `裁决日预算已满(${JUDGE_DAILY_LIMIT}/日/会话)` };
38
+ }
39
+
40
+ try {
41
+ const route = await resolveRoute(ctx);
42
+ if (!route) return { action: "unavailable", note: "无法解析模型路由(追随会话模型解析失败)" };
43
+ const rawCfg = Array.isArray(state.configs)
44
+ ? state.configs.find((c) => String(c.ruleId) === String(violation.ruleId))
45
+ : undefined;
46
+ const prompt = [
47
+ "你是 DSH 规则裁决器。判断下面这段【执行者回复】是否违反【规则】。只输出 JSON,不要其他内容。",
48
+ 'JSON 格式:{"violated": true|false, "note": "一句话理由"}',
49
+ "判定要点:",
50
+ "1. 回复若是在表达合规声明(如“我不再/将避免/规则 X 已自证/无重复推销”)或引述规则文本——violated=false;",
51
+ "2. 只有确实发生规则禁止的行为(如真的再次推销/真的发布未声明/真的遗漏事项)才 violated=true;",
52
+ "3. 拿不准时给出倾向性判断(false 偏不打扰)。",
53
+ "规则编号:" + violation.ruleId,
54
+ "规则标题:" + violation.title,
55
+ "检测提示:" + String(violation.reason || ""),
56
+ "规则正文:" + (rawCfg?.body || "(未提供)"),
57
+ "执行者回复:",
58
+ text,
59
+ "只输出 JSON。"
60
+ ].join("\n");
61
+ const messages = [{ role: "user", content: [{ type: "text", text: prompt }] }];
62
+ let out = "";
63
+ for await (const chunk of ctx.llm.stream({
64
+ provider: route.provider,
65
+ model: route.model,
66
+ messages,
67
+ maxTokens: 300,
68
+ temperature: 0
69
+ })) {
70
+ if (chunk && chunk.type === "text-delta" && typeof chunk.text === "string") out += chunk.text;
71
+ }
72
+ budget.count += 1;
73
+ state.judgeBudget.set(bKey, budget);
74
+ const parsed = parseJsonOutput(out);
75
+ const verdict = {
76
+ action: parsed?.violated === true ? "deliver" : "suppress",
77
+ note: typeof parsed?.note === "string" ? parsed.note.slice(0, 120) : "",
78
+ model: route.model
79
+ };
80
+ state.judgeCache.set(key, verdict);
81
+ return verdict;
82
+ } catch (error) {
83
+ return { action: "unavailable", note: `裁决调用失败:${error instanceof Error ? error.message : String(error)}` };
84
+ }
85
+ }
@@ -7,7 +7,10 @@ export async function resolveRoute(ctx) {
7
7
  try {
8
8
  const providers = ctx.llm.listProviders?.() || [];
9
9
  if (providers.length === 0) return null;
10
- const provider = process.env.DSH_LLM_PROVIDER || providers[0]?.name || providers[0];
10
+ // v0.5.7 修复(实弹抓到,2026-08-26):必须用 provider **id**(如 "deepseek-official")调
11
+ // listModels/resolve——pi-ai 按 id 找 profile,name(显示名)可能不同 → 传 name 会抛
12
+ // NO_ADAPTER → 全部分析失败(旧 llmIntent 自启用起 31 次调用全降级的同根因)。
13
+ const provider = process.env.DSH_LLM_PROVIDER || providers[0]?.id || providers[0]?.name || providers[0];
11
14
  const models = await ctx.llm.listModels?.(provider);
12
15
  const model = process.env.DSH_LLM_MODEL || (models && models[0]?.id) || (models && models[0]) || null;
13
16
  if (!model) return null;
@@ -35,7 +38,7 @@ function buildPrompt(rule) {
35
38
  ].join("\n");
36
39
  }
37
40
 
38
- function parseJsonOutput(text) {
41
+ export function parseJsonOutput(text) {
39
42
  const t = String(text || "").trim();
40
43
  const fence = t.match(/```(?:json)?\s*([\s\S]*?)```/);
41
44
  const raw = fence ? fence[1].trim() : t;
package/lib/core/paths.js CHANGED
@@ -33,3 +33,8 @@ export function disabledRulesFilePath() {
33
33
  export function baselineFilePath() {
34
34
  return join(dshHome(), "rule-engine-baseline.json");
35
35
  }
36
+
37
+ /** v0.5.7 P0-4:验证通过记录持久化文件(热重载/重启不丢——规则 23④ 证据链) */
38
+ export function verifyFilePath() {
39
+ return join(dshHome(), "rule-engine-verify.json");
40
+ }
@@ -237,7 +237,7 @@ export function isGetDateCommand(command) {
237
237
 
238
238
  const READ_ONLY_TOOLS = new Set(["read", "grep", "glob", "read_image"]);
239
239
  const READONLY_CMD_RE =
240
- /(?:\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|cat|type|dir|ls|grep|findstr|more|netstat|where|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help)\b)|(?:\bgit\s+(?:-[^\s]+\s+[^\s]+\s+|--[^\s]+\s+)*\b(?:status|log|diff|show|branch|tag|remote|rev-parse|diff-tree|ls-files)(?:\s|$))|(?:\bdsh\s+(?:--version|--help|--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bnode\s+(?:--check|--test|--version)(?:\s|$))|(?:\bnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bpnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bgh\s+(?:auth\s+status|repo\s+view)(?:\s|$))|(?:\bnpm\s+(?:ls|view)(?:\s|$))/i;
240
+ /(?:\b(?:Get-Content|Get-ChildItem|Get-Item|Get-Command|Get-Date|Select-String|Find-String|Test-Path|Get-Process|Get-Service|cat|type|dir|ls|grep|findstr|more|netstat|where|echo|Write-Output|Write-Host|Get-Location|pwd|cwd|Get-Help|help)\b)|(?:\bgit\s+(?:-[^\s]+\s+[^\s]+\s+|--[^\s]+\s+)*\b(?:status|log|diff|show|branch|tag|remote|rev-parse|diff-tree|ls-files)(?:\s|$))|(?:\bdsh\s+(?:--version|--help|--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bdsh\s+--profile\s+[^\s]+\s+(?:--dump-config|plugin\s+(?:list|show|status))(?:\s|$))|(?:\bnode\s+(?:--check|--test|--version)(?:\s|$))|(?:\bnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bpnpm\s+(?:test|run\s+test)(?:\s|$))|(?:\bgh\s+(?:auth\s+status|repo\s+view)(?:\s|$))|(?:\bnpm\s+(?:ls|view)(?:\s|$))/i;
241
241
  const MUTATING_CMD_RE =
242
242
  /(?:Set-Content|Add-Content|Out-File|Remove-Item|Move-Item|Copy-Item|Rename-Item|New-Item|Clear-Content|git\s+(?:push|commit)|rm\s+-r|rmdir\s+\/s|del\s+\/s|(?:^|[^0-9])>>|(?:^|[^0-9])>)/i;
243
243
 
@@ -298,15 +298,24 @@ export function needsApprovalReminder(userText) {
298
298
  return PLAN_INSTRUCTION_RE.test(t) && !WRITE_INSTRUCTION_RE.test(t);
299
299
  }
300
300
 
301
- let workspaceRootOverride = "";
301
+ let workspaceRoots = []; // 全局注册的工作区根(兜底层)
302
+ const sessionWorkspaceRoots = new Map(); // v0.5.7 P0-3:会话→工作区根(精确层,根随会话走)
302
303
 
303
- /** 由插件 apply 阶段从 workspaceRegistry 设置工作区根目录 */
304
+ /** 由插件 apply 阶段从 workspaceRegistry 设置工作区根目录(单根兼容入口) */
304
305
  export function setWorkspaceRoot(p) {
305
- workspaceRootOverride = p || "";
306
+ setWorkspaceRoots(p ? [p] : []);
306
307
  }
307
308
 
308
- function workspaceRoot() {
309
- return workspaceRootOverride || process.env.DSH_WORKSPACE || "";
309
+ /** v0.5.7 P0-3:注册全部工作区根(多工作区环境;兜底层用) */
310
+ export function setWorkspaceRoots(paths) {
311
+ workspaceRoots = Array.isArray(paths) ? paths.filter((p) => typeof p === "string" && p.length > 0) : [];
312
+ }
313
+
314
+ /** v0.5.7 P0-3:注册"会话→工作区根"映射——多工作区正确模型:根随会话走(写其他工作区=外部,需授权) */
315
+ export function setSessionWorkspaceRoot(sessionId, root) {
316
+ if (sessionId && typeof root === "string" && root.length > 0) {
317
+ sessionWorkspaceRoots.set(String(sessionId), root);
318
+ }
310
319
  }
311
320
 
312
321
  function isPathInside(target, root) {
@@ -320,11 +329,26 @@ function normalizePathForCompare(p) {
320
329
  return String(p).replace(/\\/g, "/").toLowerCase().replace(/\/+$/, "");
321
330
  }
322
331
 
323
- /** 判断路径是否在工作区外(用于“工作区外写入”敏感判定) */
324
- export function isOutsideWorkspace(p) {
332
+ /**
333
+ * 判断路径是否在工作区外(用于"工作区外写入"敏感判定)。
334
+ * v0.5.7 P0-3(用户拍板多工作区模型,2026-08-26):
335
+ * ① 会话已知且已映射 → 只用该会话的工作区根(精确:写其他工作区/他盘 = 外部 = 要授权);
336
+ * ② 无会话映射 → 全局注册根列表(任一命中=内部);
337
+ * ③ 仍无 → 环境变量/进程 cwd(最后尝试);
338
+ * ④ 全不可得 → 保守判"内部"(受保护文件/数据目录有独立清单兜底,避免工作区内普通文件被
339
+ * 误判外→12A 全量要授权——此前"只读脚本被拦"的根因)。
340
+ */
341
+ export function isOutsideWorkspace(p, sessionId) {
325
342
  if (typeof p !== "string") return false;
326
343
  if (!/^[a-z]:[\\/]/i.test(p) && !p.startsWith("/")) return false; // 只判断绝对路径
327
- return !isPathInside(p, workspaceRoot());
344
+ if (sessionId) {
345
+ const root = sessionWorkspaceRoots.get(String(sessionId));
346
+ if (root) return !isPathInside(p, root);
347
+ }
348
+ const roots = workspaceRoots.length > 0 ? workspaceRoots
349
+ : [process.env.DSH_WORKSPACE, process.cwd()].filter(Boolean);
350
+ if (roots.length === 0) return false;
351
+ return !roots.some((root) => isPathInside(p, root));
328
352
  }
329
353
 
330
354
  const ABS_PATH_RE = /[A-Za-z]:[\\/][^\s'"`,。;:!?()【】《》、]+/g;
@@ -360,9 +384,9 @@ export function absolutePathTokens(command) {
360
384
  return [...new Set([...quoted, ...unquoted])];
361
385
  }
362
386
 
363
- function commandHasOutsideWrite(cmd) {
387
+ function commandHasOutsideWrite(cmd, sessionId) {
364
388
  if (typeof cmd !== "string") return false;
365
- return writeTargetPathsFromCommand(cmd).some((p) => isOutsideWorkspace(p));
389
+ return writeTargetPathsFromCommand(cmd).some((p) => isOutsideWorkspace(p, sessionId));
366
390
  }
367
391
 
368
392
  export const GENERIC_EXEC_TOOLS = new Set([
@@ -398,6 +422,18 @@ export function isAuditCommand(command) {
398
422
  return false;
399
423
  }
400
424
 
425
+ /**
426
+ * v0.5.7 P0-1(用户拍板 2026-08-26):验证类命令判定——运行测试/冷加载探针/挂载审计/语法检查。
427
+ * 与"修/改/写"授权伴生放行使用:用户授权修改后,验证是变更的正常闭环,不再被
428
+ * write vs command 类型不匹配误拦(今天实弹连卡 4 次)。
429
+ */
430
+ export function isVerificationCommand(command) {
431
+ if (typeof command !== "string") return false;
432
+ if (/\b(?:node|npm|npx)\s+[^\n]*(?:run-all\.mjs|check-plugin-load\.mjs|audit-mount-consistency\.mjs|loader-smoke\.e2e\.mjs|verify-all\.mjs|health-audit\.mjs|--check|--test)\b/i.test(command)) return true;
433
+ if (/\b(?:npm|pnpm)\s+(?:test|run\s+test)\b/i.test(command)) return true;
434
+ return false;
435
+ }
436
+
401
437
  /** 审计输出是否明确通过(无 DUPLICATES/INCONSISTENT/MISSING 且出现通过标记) */
402
438
  export function auditOutputPassed(output) {
403
439
  const text = String(output || "");
@@ -409,8 +445,10 @@ export function auditOutputFailed(output) {
409
445
  return /DUPLICATES FOUND|INCONSISTENT|\[MISSING\]/i.test(String(output || ""));
410
446
  }
411
447
 
412
- /** 判断是否命中敏感操作(需要授权证据;只读操作永远不算) */
413
- export function isSensitiveToolCall(toolName, args) {
448
+ /** 判断是否命中敏感操作(需要授权证据;只读操作永远不算)
449
+ * v0.5.7 P0-3(复查修复 2026-08-26):透传 sessionId——"工作区外"判定需按会话精确根(多工作区
450
+ * 模型:会话根优先;此前遗漏透传导致会话级根从未在运行时激活,只有全局兜底层生效)。 */
451
+ export function isSensitiveToolCall(toolName, args, sessionId) {
414
452
  if (isReadOnlyTool(toolName, args)) return false;
415
453
  const name = String(toolName || "");
416
454
  const p = pathTarget(args);
@@ -423,13 +461,13 @@ export function isSensitiveToolCall(toolName, args) {
423
461
  // P0-1c:受保护文件名检查必须配合「写类命令」——纯描述性文本(如 gh release --notes 里的
424
462
  // "AGENTS.md" 字样、echo 输出内容)不再触发敏感判定;真写类命令(Set-Content/Remove-Item 等
425
463
  // 命中 MUTATING_CMD_RE)提到受保护文件仍判敏感。
426
- if (cmd && (SENSITIVE_CMD.test(cmd) || (MUTATING_CMD_RE.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) || commandHasOutsideWrite(cmd))) return true;
464
+ if (cmd && (SENSITIVE_CMD.test(cmd) || (MUTATING_CMD_RE.test(cmd) && PROTECTED_FILENAME_RE.test(cmd)) || commandHasOutsideWrite(cmd, sessionId))) return true;
427
465
  }
428
466
  if (name === "edit" || name === "write") {
429
- if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
467
+ if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p, sessionId))) return true;
430
468
  }
431
469
  if (name === "str_replace_editor" && args?.command !== "view") {
432
- if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p))) return true;
470
+ if (p && (isProtectedConfigPath(p) || isOutsideWorkspace(p, sessionId))) return true;
433
471
  }
434
472
  if (GENERIC_EXEC_TOOLS.has(name)) return true;
435
473
  return false;
@@ -447,3 +485,32 @@ export const TERM_EXPLANATION_RE =
447
485
  /** 建议类表述(规则 16 重复推销:会话内同类建议 ≥2 → 自证) */
448
486
  export const SUGGEST_RE =
449
487
  /我建议|建议用|推荐|更优方案|建议(?:是|考虑|换成|用|来)|档位建议|不如换|优化建议/;
488
+
489
+ /**
490
+ * v0.5.7 P0.5-6(用户拍板):否定/合规声明语境("不再/避免/停止/无 XX")——那不是重复推销。
491
+ * 粗筛层直接不计数(合规声明不产生重复计数);语义判断权仍归裁决器,
492
+ * 此处只为省钱(不送 LLM 裁决)并消除"自证--触发"噪音。
493
+ */
494
+ export function isNegatingSuggestion(text) {
495
+ if (typeof text !== "string") return false;
496
+ return /(?:不再|不重复|避免|停止|取消|暂不|未再|不要(?:再)?|以后(?:不|别)|今后(?:不|别)|已呼应|已回应|无(?:新)?(?:建议|重复)|没有(?:新)?(?:建议|重复)|只等待)/.test(text);
497
+ }
498
+
499
+ /**
500
+ * v0.5.7 P0.5-5(用户拍板):承诺词处于引述/改写语境("把'保证'改成…")→ 是引述不是承诺。
501
+ * 判定:词前 8 字符内配对开引号 + 词后 8 字符内配对闭引号;或前后 12 字符内引述标记词。
502
+ */
503
+ export function isPromiseQuoteContext(text) {
504
+ if (typeof text !== "string") return false;
505
+ for (const m of text.matchAll(/(?:包在我身上|肯定能|绝对没问题|保证|一定可以|万无一失)/g)) {
506
+ const before = text.slice(0, m.index);
507
+ const after = text.slice(m.index + m[0].length);
508
+ // 引述包装 = 承诺词两侧 8 字符内都有引号(ASCII/中文弯引号均算;不区分左右——
509
+ // ASCII 单引号左右同形,分区判定会自相矛盾;双侧近邻引号即视为被引述包裹)。
510
+ const openNear = /[“"「『‘'””」』’]/.test(before.slice(-8));
511
+ const closeNear = /[“"「『‘'””」』’]/.test(after.slice(0, 8));
512
+ if (openNear && closeNear) return true;
513
+ if (/引述|引用|原文|写过|改成|改为|换成/.test(before.slice(-12)) || /改成|改为|换成/.test(after.slice(0, 12))) return true;
514
+ }
515
+ return false;
516
+ }
@@ -0,0 +1,43 @@
1
+ // semantic.js - 投递资格语义层(v0.5.7,用户拍板 2026-08-26)。
2
+ // 原则:只有"错误的行为"才值得被提醒(D 级无法硬拦,提醒是唯一执行手段)。
3
+ // 本文件只放纯函数,不做 LLM——"是否错误"的裁决在 judge.js;
4
+ // 这里的职责是:① 注入轮不检测判定(乒乓根治);② 投递资格闸(提醒一次并记住 + 会话每小时预算)。
5
+
6
+ /** 会话弹窗预算:每小时最多 3 条注入(跨全部规则,含聚合)。 */
7
+ export const INJECT_BUDGET_PER_HOUR = 3;
8
+
9
+ /**
10
+ * 第 0 站(检测前传):只有"真实用户在场"的回合才做违规检测与投递。
11
+ * 真实用户在场 = 本回合 user/message 来源为 user,或 ask_user_question 获得用户答复。
12
+ * 引擎注入触发的回合(source=plugin / 依赖注入出的无用户消息轮)不检测——
13
+ * 那些回合的"回复-检测-再注入"正是乒乓循环的燃料(实测 2026-08-26 12:37 六圈)。
14
+ */
15
+ export function shouldDetectTurn({ realUserSeen }) {
16
+ return realUserSeen === true;
17
+ }
18
+
19
+ /**
20
+ * 投递资格闸:决定这条注入该不该"进对话"(无论结果如何都会写审计)。
21
+ * 规则:① 同规则同会话仅投递一次(__self-cert 聚合除外);
22
+ * ② 会话每小时至多 INJECT_BUDGET_PER_HOUR 条(超出只审计不投递)。
23
+ * 两者同时满足才投递;被拦的违规全部留在 /guard log 审计(inject-skip)。
24
+ * @param {object} p
25
+ * @param {number} p.now 当前时间戳
26
+ * @param {string} p.ruleId 规则 id(__self-cert 不做每规则去重)
27
+ * @param {number|null} p.firstAt 该规则在本会话首次投递时间(null=从未投递)
28
+ * @param {number[]} p.budgetTimes 本会话投递时间戳列表(用于预算窗口统计)
29
+ * @returns {{ok:true}|{ok:false, reason:"dedup"|"budget"}}
30
+ */
31
+ export function shouldDeliver({ now, ruleId, firstAt, budgetTimes }) {
32
+ // 机制类注入(`__` 前缀:__engram-gap / __version-guard / __self-cert 聚合等)是事件驱动、
33
+ // 低频且重要,不做"每规则一次"去重(总量由下面的每小时预算约束);只有真实规则 id 才"提醒一次并记住"。
34
+ const isMechanism = String(ruleId || "").startsWith("__");
35
+ if (!isMechanism && firstAt != null) {
36
+ return { ok: false, reason: "dedup" };
37
+ }
38
+ const recent = (budgetTimes || []).filter((t) => now - t < 60 * 60 * 1000);
39
+ if (recent.length >= INJECT_BUDGET_PER_HOUR) {
40
+ return { ok: false, reason: "budget" };
41
+ }
42
+ return { ok: true };
43
+ }
package/lib/core/state.js CHANGED
@@ -28,6 +28,11 @@ export function createState() {
28
28
  sessions: new Map(),
29
29
  retryCounts: new Map(),
30
30
  injectCounts: new Map(),
31
+ injectAt: new Map(), // v0.5.7:key `${sessionId}:${ruleId}` → 首次投递时间(提醒一次并记住)
32
+ injectBudget: new Map(), // v0.5.7:sessionId → 投递时间戳数组(会话每小时预算)
33
+ judgeCache: new Map(), // v0.5.7:sha256(ruleId+text) → 裁决结果(同文本不重复调用)
34
+ judgeBudget: new Map(), // v0.5.7:`${sessionId}:${UTC日期}` → {count}(每日裁决预算)
35
+ judgeFn: null, // v0.5.7:可注入裁决函数(生产=judge.js LLM 实现;测试=stub)
31
36
  globalAuthorizations: [],
32
37
  skillNames: null,
33
38
  llmEnrichedKeys: new Set(),
@@ -97,6 +102,21 @@ export function pruneState(state) {
97
102
  const keys = [...state.injectCounts.keys()];
98
103
  for (let i = 0; i < keys.length - MAX_INJECT_KEYS; i++) state.injectCounts.delete(keys[i]);
99
104
  }
105
+ // v0.5.7:投递资格/裁决状态超限清理(优先保留新 key 语义——按插入序删最旧)
106
+ for (const m of [state.injectAt, state.judgeCache]) {
107
+ if (m.size > MAX_INJECT_KEYS) {
108
+ const keys = [...m.keys()];
109
+ for (let i = 0; i < keys.length - MAX_INJECT_KEYS; i++) m.delete(keys[i]);
110
+ }
111
+ }
112
+ if (state.injectBudget.size > MAX_SESSIONS) {
113
+ const keys = [...state.injectBudget.keys()];
114
+ for (let i = 0; i < keys.length - MAX_SESSIONS; i++) state.injectBudget.delete(keys[i]);
115
+ }
116
+ if (state.judgeBudget.size > MAX_SESSIONS * 2) {
117
+ const keys = [...state.judgeBudget.keys()];
118
+ for (let i = 0; i < keys.length - MAX_SESSIONS * 2; i++) state.judgeBudget.delete(keys[i]);
119
+ }
100
120
  // 清理过期授权(顺便释放内存)
101
121
  for (const s of state.sessions.values()) {
102
122
  s.authorizations = s.authorizations.filter((a) => !a.expiresAt || a.expiresAt > now);
@@ -112,6 +132,7 @@ export function getSessionState(state, sessionId) {
112
132
  if (!s) {
113
133
  s = {
114
134
  id: key,
135
+ ruleStats: {}, // C2:每规则 detected/suppressed/injected 统计(会话级,跨回合累计)
115
136
  manualReadSeen: false,
116
137
  lastUserText: "",
117
138
  selfCertCount: new Map(),
@@ -205,6 +226,7 @@ export function freshTurn() {
205
226
  return {
206
227
  number: 0,
207
228
  userText: "",
229
+ realUserSeen: false, // v0.5.7:本回合是否有真实用户消息(注入轮不检测的依据)
208
230
  askSeen: false,
209
231
  askRejected: false, // 本回合 ask 已被拒(防连环 ask,弹窗消减)
210
232
  questionOnly: false,
@@ -8,7 +8,9 @@ import {
8
8
  URL_RE,
9
9
  TECH_TERM_RE,
10
10
  TERM_EXPLANATION_RE,
11
- SUGGEST_RE
11
+ SUGGEST_RE,
12
+ isNegatingSuggestion,
13
+ isPromiseQuoteContext
12
14
  } from "./patterns.js";
13
15
  import { detectOverengineeringText } from "./overengineering.js";
14
16
 
@@ -51,6 +53,20 @@ function isMostlyEnglish(text) {
51
53
  return letters > 30 && cjk === 0;
52
54
  }
53
55
 
56
+ /**
57
+ * 注入噪音治理 v0.5.6(建议2/3 已自证抑制):同一条回复中已含"规则 X 已按/已自证/已核对…"标记时,
58
+ * 该规则本轮不再计为新违规——打断"自证回复复述触发词 → 再次注入"的自循环。
59
+ * 仅对 kind=self-certify 的 D 级自证命中生效;B 级纠察(kind=correct)不受影响(审计优先)。
60
+ */
61
+ export function isSelfCertified(text, ruleId) {
62
+ if (!text || !ruleId) return false;
63
+ const id = String(ruleId).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
64
+ const tail = "(?:已按|已按要求|已自证|已核对|已核实|核实通过|合规|已满足|已一次性说明|已合并提出|已回应|已实施|已落地)";
65
+ const suffix = "(?:[①②③④⑤⑥⑦⑧⑨⑩]|\\.\\d+|\\d+)?";
66
+ const re = new RegExp(`(?:规则\\s*${id}${suffix}\\s*${tail}|${tail}\\s*规则\\s*${id}${suffix})`);
67
+ return re.test(text);
68
+ }
69
+
54
70
  /**
55
71
  * 检测一次 assistant/message 的 B/D 级违规。
56
72
  * @param {object} options
@@ -74,7 +90,8 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
74
90
  }
75
91
 
76
92
  const promiseCfg = byId.get("7");
77
- if (promiseCfg && PROMISE_WORDS.test(text)) {
93
+ // v0.5.7 P0.5-5:承诺词处于引述/改写语境("把'保证'改成…")→ 引述不是承诺,不触发
94
+ if (promiseCfg && PROMISE_WORDS.test(text) && !isPromiseQuoteContext(text)) {
78
95
  hits.push({
79
96
  ruleId: "7",
80
97
  title: promiseCfg.title,
@@ -119,8 +136,10 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
119
136
  }
120
137
 
121
138
  // 批次 5:重复推销(规则 16——会话内同类建议 ≥2 次 → 自证,防反复推销)
139
+ // v0.5.7 P0.5-6:否定/合规声明语境("不再/无 XX")不计数——那不是重复推销(粗筛省钱,
140
+ // 语义判断权仍归裁决器)
122
141
  const suggestCfg = byId.get("16");
123
- if (suggestCfg && SUGGEST_RE.test(text)) {
142
+ if (suggestCfg && SUGGEST_RE.test(text) && !isNegatingSuggestion(text)) {
124
143
  session.suggestionCounts = session.suggestionCounts || {};
125
144
  session.suggestionCounts.general = (session.suggestionCounts.general || 0) + 1;
126
145
  if (session.suggestionCounts.general >= 2) {
@@ -223,5 +242,26 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
223
242
  });
224
243
  }
225
244
 
226
- return hits;
245
+ // C2 统计:命中(detected)计数——在抑制过滤前逐条累计
246
+ for (const h of hits) {
247
+ const st = (session.ruleStats ||= {});
248
+ const k = (st[h.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
249
+ k.detected++;
250
+ }
251
+ // 注入噪音治理 v0.5.6(建议2/3):已自证标记("规则 X 已按/已自证/已核对…")→ 该规则本轮不计新违规
252
+ const kept = hits.filter((h) => h.kind !== "self-certify" || !isSelfCertified(text, h.ruleId));
253
+ // C2 统计:被抑制(suppressed)计数
254
+ for (const h of hits) {
255
+ if (!kept.includes(h)) {
256
+ const st = session.ruleStats || {};
257
+ const k = st[h.ruleId];
258
+ if (k) k.suppressed++;
259
+ }
260
+ }
261
+ // v0.5.7:D 级语义型命中 = 嫌疑(awaitingJudge)——词表只产嫌疑,"是否错误"由 judge 裁决。
262
+ // 只有裁决 = 违规 的才进入投递(兑现"只有错误的行为才值得被提醒")。
263
+ for (const h of kept) {
264
+ if (h.kind === "self-certify") h.awaitingJudge = true;
265
+ }
266
+ return kept;
227
267
  }
package/lib/index.js CHANGED
@@ -25,7 +25,9 @@ import {
25
25
  isReadOnlyTool,
26
26
  needsApprovalReminder,
27
27
  PRIVATE_NETWORK_RE,
28
- setWorkspaceRoot
28
+ setWorkspaceRoot,
29
+ setWorkspaceRoots,
30
+ setSessionWorkspaceRoot
29
31
  } from "./core/patterns.js";
30
32
  import {
31
33
  askQuestionCoreText,
@@ -56,6 +58,8 @@ import { SAFE_UNCOVERED_TOOLS } from "./core/guard-core.js";
56
58
  import { toolClass } from "./core/tool-catalog.js";
57
59
  import { state } from "./core/runtime.js";
58
60
  import { detectViolations, extractAssistantText } from "./core/text-detect.js";
61
+ import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
62
+ import { judgeViolation } from "./core/judge.js";
59
63
  import {
60
64
  applyContract,
61
65
  contractSummary,
@@ -75,7 +79,7 @@ import {
75
79
  recordAction
76
80
  } from "./core/overengineering.js";
77
81
  import { writeUnderstanding } from "./core/understanding-store.js";
78
- import { agentsFilePath, auditFilePath } from "./core/paths.js";
82
+ import { agentsFilePath, auditFilePath, verifyFilePath } from "./core/paths.js";
79
83
  import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
80
84
  import { computeMountSignature, profileNameFromArgs } from "./core/mount-signature.js";
81
85
  import { detectSilentError, extractToolOutput } from "./core/silent-error.js";
@@ -173,9 +177,39 @@ const pendingInjectTimers = new Set();
173
177
  function maybeInject(ctx, sessionId, violation) {
174
178
  if (!pluginConfig.correctInject) return;
175
179
  const key = `${sessionId}:${violation.ruleId}`;
180
+ // v0.5.7 投递资格闸(用户拍板"提醒一次并记住 + 会话每小时 3 条预算",2026-08-26):
181
+ // 同规则同会话仅投递一次(__self-cert 聚合除外),会话每小时至多 3 条。
182
+ // 被拦的违规仍写审计(inject-skip)——只有"弹进对话"被省掉,审计完整性不降级。
183
+ const gate = shouldDeliver({
184
+ now: Date.now(),
185
+ ruleId: violation.ruleId,
186
+ firstAt: state.injectAt.get(key) ?? null,
187
+ budgetTimes: state.injectBudget.get(sessionId) || []
188
+ });
189
+ if (!gate.ok) {
190
+ audit({
191
+ kind: "inject-skip",
192
+ rule: violation.ruleId,
193
+ name: "投递资格拦截",
194
+ event: "inject",
195
+ reason: `未投递(${gate.reason}):${String(violation.reason || "").slice(0, 80)}`,
196
+ session: sessionId
197
+ });
198
+ return;
199
+ }
200
+ state.injectAt.set(key, Date.now());
201
+ const budgetArr = state.injectBudget.get(sessionId) || [];
202
+ budgetArr.push(Date.now());
203
+ state.injectBudget.set(sessionId, budgetArr.slice(-20));
176
204
  const count = state.injectCounts.get(key) || 0;
177
- if (count >= pluginConfig.injectLimitPerRulePerSession) return;
178
205
  state.injectCounts.set(key, count + 1);
206
+ // C2 统计:注入(injected)计数(实际投递尝试,上限内)
207
+ {
208
+ const sStat = getSessionState(state, sessionId);
209
+ const st = (sStat.ruleStats ||= {});
210
+ const k = (st[violation.ruleId] ||= { detected: 0, suppressed: 0, injected: 0 });
211
+ k.injected++;
212
+ }
179
213
  const timer = setTimeout(() => {
180
214
  pendingInjectTimers.delete(timer);
181
215
  try {
@@ -207,6 +241,53 @@ function maybeInject(ctx, sessionId, violation) {
207
241
  pendingInjectTimers.add(timer);
208
242
  }
209
243
 
244
+ /**
245
+ * v0.5.7:D 级嫌疑异步裁决并投递(用户拍板"只有错误的行为才值得被提醒")。
246
+ * 裁决函数可注入(state.judgeFn,测试用 stub;生产 = judge.js 的 LLM 实现)。
247
+ * 结果审计:judge-pass(确认违规→投递)/ judge-false(合规声明/引述→不投)/
248
+ * judge-unavailable(失败/超时/预算满→fail-closed 不投)——三类全部留 /guard log 可对质。
249
+ */
250
+ async function deliverSuspects(ctx, sessionId, suspects, text) {
251
+ 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
275
+ });
276
+ return null;
277
+ }
278
+ }));
279
+ const deliver = results.filter(Boolean);
280
+ if (deliver.length > 1) {
281
+ const briefs = deliver.map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`).join(";");
282
+ maybeInject(ctx, sessionId, {
283
+ ruleId: "__self-cert",
284
+ reason: `裁决通过 ${deliver.length} 项:${briefs}——请按 /guard log 明细应对`
285
+ });
286
+ } else if (deliver.length === 1) {
287
+ maybeInject(ctx, sessionId, deliver[0]);
288
+ }
289
+ }
290
+
210
291
  async function refreshSkills(ctx) {
211
292
  try {
212
293
  const list = await ctx.skills.list();
@@ -216,6 +297,15 @@ async function refreshSkills(ctx) {
216
297
  }
217
298
  }
218
299
 
300
+ /** v0.5.7 P0-4:验证通过记录持久化(文件损坏/写失败不阻断运行时) */
301
+ function persistVerifyPass(state) {
302
+ try {
303
+ writeFileSync(verifyFilePath(), JSON.stringify((state.verifyPass || []).slice(-100)), "utf8");
304
+ } catch {
305
+ // 持久化失败仅降级(内存态仍有效),不阻断
306
+ }
307
+ }
308
+
219
309
  export function handleSessionEvent(ctx, session, event) {
220
310
  maybeReloadIfChanged(state);
221
311
  const sid = session?.id || "global";
@@ -298,6 +388,7 @@ export function handleSessionEvent(ctx, session, event) {
298
388
  }
299
389
  s.lastUserText = text;
300
390
  s.turn.userText = text;
391
+ s.turn.realUserSeen = true; // v0.5.7:真实用户在场(注入轮不检测的依据——乒乓根治)
301
392
  s.turn.questionOnly = isQuestionMessage(text);
302
393
  s.turn.intents = parseUserIntents(text);
303
394
  // 规则 22 粒度升级(2026-08-24):本回合授权范围 = 各 execute 子句推导出的 type+path 范围
@@ -560,10 +651,11 @@ export function handleSessionEvent(ctx, session, event) {
560
651
  // pendingToolCalls 状态(what 降级记录),verify-pass 语义不变(输出即证据)。
561
652
  if (!isError) {
562
653
  const output = extractToolOutput(d);
563
- if (/\bALL TESTS PASSED\b|RESULT:\s*PASS/.test(output)) {
654
+ if (/\bALL TESTS PASSED\b|RESULT:\s*PASS|\bVERIFY ALL OK\b/.test(output)) {
564
655
  if (!state.verifyPass) state.verifyPass = [];
565
656
  state.verifyPass.push({ at: Date.now(), sessionId: sid, what: pendingCall?.name || "tool-result" });
566
657
  if (state.verifyPass.length > 100) state.verifyPass.splice(0, state.verifyPass.length - 100);
658
+ persistVerifyPass(state); // v0.5.7 P0-4:持久化(热重载/重启不丢)
567
659
  }
568
660
  }
569
661
 
@@ -590,6 +682,7 @@ export function handleSessionEvent(ctx, session, event) {
590
682
  if (pending) {
591
683
  const result = d.result ?? d.value ?? d;
592
684
  if (askResultApproved(result)) {
685
+ s.turn.realUserSeen = true; // v0.5.7:ask 弹窗答复也算"真实用户在场"
593
686
  const qText = askQuestionText(pending.questions);
594
687
  const selectedText = askResultSelectedText(result);
595
688
  // 授权范围只取“问题核心 + 用户实际选择”,不再把全部选项描述纳入;
@@ -664,6 +757,9 @@ export function handleSessionEvent(ctx, session, event) {
664
757
  }
665
758
  if (event.type === "assistant/message") {
666
759
  const text = extractAssistantText(d.message);
760
+ // v0.5.7 第 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
761
+ // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
762
+ if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
667
763
  // 交付声明机器闸门(机制批 M3,规则 23④):完成类声明 → 核对同会话 30 分钟内 verify-pass 记录,缺失注入纠正
668
764
  if (/完成|已通过|已修复|搞定|验证通过|全部通过|修复完成|落盘完成/.test(text)) {
669
765
  const windowStart = Date.now() - 30 * 60 * 1000;
@@ -720,7 +816,25 @@ export function handleSessionEvent(ctx, session, event) {
720
816
  reason: v.reason,
721
817
  session: sid
722
818
  });
723
- maybeInject(ctx, sid, v);
819
+ }
820
+ // v0.5.7 分流水线:B 级机器型(correct,词表=确定证据)立即投递;
821
+ // D 级语义型命中 = 嫌疑(awaitingJudge)→ 先裁决,只有裁决=违规的才投递
822
+ // (用户拍板:"只有错误的行为才值得被提醒"——词表只产嫌疑,不再定罪)。
823
+ const immediate = violations.filter((v) => v.kind !== "self-certify");
824
+ const suspects = violations.filter((v) => v.awaitingJudge === true);
825
+ if (immediate.length > 1) {
826
+ const briefs = immediate
827
+ .map((v) => `规则 ${v.ruleId}(${String(v.reason || "").slice(0, 60)})`)
828
+ .join(";");
829
+ maybeInject(ctx, sid, {
830
+ ruleId: "__self-cert",
831
+ reason: `本轮检出 ${immediate.length} 项:${briefs}——请按 /guard log 明细应对`
832
+ });
833
+ } else if (immediate.length === 1) {
834
+ maybeInject(ctx, sid, immediate[0]);
835
+ }
836
+ if (suspects.length > 0) {
837
+ void deliverSuspects(ctx, sid, suspects, text);
724
838
  }
725
839
  if (violations.length) {
726
840
  state.lastActive = violations.map((v) => ({ ruleId: v.ruleId, title: v.title, reason: v.reason }));
@@ -945,12 +1059,25 @@ async function executeGuard(ctx, invocation) {
945
1059
  // ── 插件主体 ────────────────────────────────────────────────────────────────
946
1060
 
947
1061
  export function apply(ctx) {
948
- // 0. workspaceRegistry 获取真实工作区根目录,避免 process.cwd() 误判
1062
+ // 0. v0.5.7 P0-3:注册全部工作区根(多工作区环境;精确层=会话级,在 guard 回调按会话同步)
949
1063
  try {
950
- const ws = ctx.workspaceRegistry?.list?.()?.[0]?.path;
951
- setWorkspaceRoot(ws || process.env.DSH_WORKSPACE || "");
1064
+ const list = ctx.workspaceRegistry?.list?.() || [];
1065
+ setWorkspaceRoots(list.map((e) => e && (e.path || e.record?.path)).filter(Boolean));
952
1066
  } catch {
953
- setWorkspaceRoot(process.env.DSH_WORKSPACE || "");
1067
+ setWorkspaceRoots([]);
1068
+ }
1069
+
1070
+ // v0.5.7 P0-4:加载持久化验证记录(热重载/重启后规则 23④ 证据链不丢;仅保留 30 分钟内)
1071
+ try {
1072
+ const raw = readFileSync(verifyFilePath(), "utf8");
1073
+ const arr = JSON.parse(raw);
1074
+ if (Array.isArray(arr)) {
1075
+ state.verifyPass = arr
1076
+ .filter((v) => v && v.at && Date.now() - Number(v.at) < 30 * 60 * 1000)
1077
+ .slice(-100);
1078
+ }
1079
+ } catch {
1080
+ // 首次运行/文件缺失:无历史记录,正常
954
1081
  }
955
1082
 
956
1083
  // 0.0.1 LLM 意图兜底配置(guardDecision 为纯函数,经 state 传入)
@@ -1005,6 +1132,17 @@ export function apply(ctx) {
1005
1132
  ctx.effect(
1006
1133
  function* () {
1007
1134
  yield ctx.tools.guard((exec) => {
1135
+ // v0.5.7 P0-3:多工作区正确模型——根随会话走。每个工具调用前按当前会话同步其工作区根
1136
+ //(官方 workspaceRegistry.sessionPath 提供会话→工作区映射;新工作区零维护自动识别)。
1137
+ try {
1138
+ const gsid = sessionIdOfExec(exec);
1139
+ if (gsid && gsid !== "global" && ctx.workspaceRegistry?.sessionPath) {
1140
+ const root = ctx.workspaceRegistry.sessionPath(gsid);
1141
+ if (root) setSessionWorkspaceRoot(gsid, root);
1142
+ }
1143
+ } catch {
1144
+ // 会话未映射时安静走全局兜底,不阻塞裁决
1145
+ }
1008
1146
  // 落盘授权粒度提醒(机制批 M7,2026-08-24):受保护文件统一入口调用 + 本回合用户消息为方案性指令
1009
1147
  //(调整/补充/评估/建议且无落盘词)→ approval-gap 审计 + 注入提醒(规则 22 自证③:方案性指令 ≠ 落盘授权)
1010
1148
  {
@@ -1127,15 +1265,32 @@ export function apply(ctx) {
1127
1265
  }
1128
1266
  });
1129
1267
 
1130
- // 1.1 任务契约 ask 通道:仅在总开关 + askEnabled 时对“应询问”动作返回 ask
1268
+ // 1.1 任务契约裁决钩子:deny 物理拒绝 + ask 通道 + 委托预算启动即计。
1269
+ // 2026-08-25 修复(预算语义偏差,实弹:第 2 个子代理被误拒——允许 N 实际 N-1):
1270
+ // 根因 = 扣减(本钩子)先于 guard 层的 decideContractAction(L1138 用已扣 contract 重判 → 2+1>2 → 误拒)。
1271
+ // 修复 = 本钩子成为契约唯一裁决点(deny 在扣减**前**判定并物理拒绝),guard 层跳过预算类 deny(防双判)。
1131
1272
  ctx.on("tools/pre-execute", async (exec, next) => {
1132
1273
  try {
1133
- if (!state.taskContract?.taskContractEnabled || !state.taskContract?.askEnabled) return next();
1274
+ if (!state.taskContract?.taskContractEnabled) return next();
1134
1275
  const sid = sessionIdOfExec(exec);
1135
1276
  const s = getSessionState(state, sid);
1136
1277
  if (!isArmed(s.contract, state.taskContract)) return next();
1137
1278
  const action = classifyAction(exec?.name, exec?.arguments);
1138
1279
  const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
1280
+ // deny(在扣减前判定,语义=本次调用发生时点)
1281
+ if (dec.outcome === "deny") {
1282
+ audit({
1283
+ kind: "task-contract-deny",
1284
+ rule: "__task-contract",
1285
+ name: `任务契约拒绝:${dec.reasonCode}`,
1286
+ event: "tools/pre-execute",
1287
+ tool: exec?.name,
1288
+ args: summarizeArgs(exec?.arguments),
1289
+ reason: dec.reason,
1290
+ session: sid
1291
+ });
1292
+ return { kind: "deny", reason: `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "请调整契约"})` };
1293
+ }
1139
1294
  // 批次 5.5(2026-08-24):委托启动即计预算——原 tool/result 扣减因 subagent 无 pendingCall 从未执行(agents 恒 0)
1140
1295
  if (dec.outcome === "allow" && action.mutability === "delegate" && action.delegationCount > 0) {
1141
1296
  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.7",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -0,0 +1,60 @@
1
+ // health-audit.mjs - 健康审计(找茬,不是证明;2026-08-26)。
2
+ // 输出"问题清单"(近 24h),无问题也要列出"查了什么":
3
+ // ① 失败/降级类统计:intent-llm(成功/失败降级)、judge-pass/false/unavailable、
4
+ // verify-gap、inject-skip、source-skip——失败可见化("LLM 意图 31 次全降级"从此自浮现);
5
+ // ② 接口接线交叉:关键导出符号在 lib 下引用计数 ≤1(仅定义,无引用)→ 疑似未接线;
6
+ // ③ 与 verify-all 的真实判例口径一致(供人工核对)。
7
+ import { readdirSync, statSync, readFileSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
12
+ const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
13
+ const now = Date.now();
14
+ const cuts = { intentFail: 0, intentOk: 0, judgePass: 0, judgeFalse: 0, judgeUnavail: 0, verifyGap: 0, injectSkip: 0, sourceSkip: 0 };
15
+
16
+ try {
17
+ for (const line of readFileSync(auditPath, "utf8").split("\n")) {
18
+ const m = line.match(/"ts":"([^"]+)"/);
19
+ if (!m || now - new Date(m[1]).getTime() > 24 * 3600 * 1000) continue;
20
+ if (line.includes('"kind":"intent-llm"')) (line.includes("LLM 失败降级") ? cuts.intentFail++ : cuts.intentOk++);
21
+ if (line.includes('"kind":"judge-pass"')) cuts.judgePass++;
22
+ if (line.includes('"kind":"judge-false"')) cuts.judgeFalse++;
23
+ if (line.includes('"kind":"judge-unavailable"')) cuts.judgeUnavail++;
24
+ if (line.includes('"kind":"verify-gap"')) cuts.verifyGap++;
25
+ if (line.includes('"kind":"inject-skip"')) cuts.injectSkip++;
26
+ if (line.includes('"kind":"source-skip"')) cuts.sourceSkip++;
27
+ }
28
+ } catch {
29
+ // 无日志文件:一切 0
30
+ }
31
+
32
+ // ② 接线交叉:导出符号引用计数(>1 = 定义+引用;=1 = 疑似仅定义未接线)
33
+ const EXPORTS = [
34
+ "setSessionWorkspaceRoot", "setWorkspaceRoots", "isVerificationCommand",
35
+ "isNegatingSuggestion", "isPromiseQuoteContext", "judgeViolation",
36
+ "shouldDetectTurn", "shouldDeliver", "isReadOnlyCommand"
37
+ ];
38
+ let libText = "";
39
+ (function walk(d) {
40
+ for (const f of readdirSync(d)) {
41
+ const p = join(d, f);
42
+ if (statSync(p).isDirectory()) walk(p);
43
+ else if (/\.js$/.test(f)) libText += readFileSync(p, "utf8") + "\n";
44
+ }
45
+ })(join(root, "lib"));
46
+ const orphans = EXPORTS.filter((n) => ((libText.match(new RegExp("\\b" + n + "\\b", "g")) || []).length) <= 1);
47
+
48
+ console.log("== 健康审计(近 24h)==");
49
+ console.log(`intent-llm 失败降级 ${cuts.intentFail} 条 / 成功 ${cuts.intentOk} 条`);
50
+ console.log(`judge-pass ${cuts.judgePass} 条 / judge-false ${cuts.judgeFalse} 条 / judge-unavailable ${cuts.judgeUnavail} 条`);
51
+ console.log(`verify-gap ${cuts.verifyGap} 条 / inject-skip ${cuts.injectSkip} 条 / source-skip ${cuts.sourceSkip} 条`);
52
+ console.log(`接线交叉:${EXPORTS.length} 个关键导出 → 疑似未接线 ${orphans.length ? orphans.join("、") : "0 个"}`);
53
+ if (cuts.judgePass + cuts.judgeFalse === 0) {
54
+ console.log("⚠️ 问题:近 24h 无真实判例——裁决器无运行证据(需实弹)");
55
+ }
56
+ if (cuts.intentFail > 0) {
57
+ console.log(`⚠️ 问题:intent-llm 失败降级 ${cuts.intentFail} 条——LLM 意图判定不可用(需查模型路由/密钥)`);
58
+ }
59
+ console.log(orphans.length ? `❌ 问题:疑似未接线:${orphans.join("、")}` : `✅ 接线:全部导出有引用`);
60
+ console.log("(已查:失败统计 8 类 + 接线交叉 9 个导出;无问题也列出如上)");
@@ -0,0 +1,81 @@
1
+ // verify-all.mjs - 交付前体检报告(2026-08-26,对齐官方 docs/testing.zh.md 分层验证)。
2
+ // 四层:① 语法(lib 全部 .js node --check)
3
+ // ② 单元(test/run-all.mjs——单元+机制层,不含 e2e)
4
+ // ③ 组合冒烟(test/loader-smoke.e2e.mjs——真实接线 + 外部世界断言)
5
+ // ④ 真实判例(外部世界:近 24h 台账中 judge-pass/judge-false 记录数;
6
+ // 0 条 = WARN 提示需实弹,≥1 条 = 有真实裁决证据——读文件,不是自我报告)
7
+ // 任一 ❌ → exit 1;⚠️(WARN)不阻塞但必须明示。
8
+ // 注意:子进程输出捕获需完整权限运行(受限模式 EPERM)。
9
+ import { execFileSync } from "node:child_process";
10
+ import { readdirSync, statSync, readFileSync } from "node:fs";
11
+ import { dirname, join } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
15
+ const lines = [];
16
+
17
+ function step(name, cmd, args) {
18
+ try {
19
+ const out = execFileSync(cmd, args, { cwd: root, encoding: "utf8", stdio: "pipe", maxBuffer: 64 * 1024 * 1024 });
20
+ lines.push(`✅ ${name}`);
21
+ return out;
22
+ } catch (e) {
23
+ lines.push(`❌ ${name}`);
24
+ lines.push(String(e.stdout || e.stderr || e.message).slice(-500));
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // ── ① 语法 ──
30
+ const files = [];
31
+ (function walk(d) {
32
+ for (const f of readdirSync(d)) {
33
+ const p = join(d, f);
34
+ if (statSync(p).isDirectory()) walk(p);
35
+ else if (/\.js$/.test(f)) files.push(p);
36
+ }
37
+ })(join(root, "lib"));
38
+ let sOk = true;
39
+ for (const f of files) {
40
+ try {
41
+ execFileSync(process.execPath, ["--check", f], { cwd: root, stdio: "pipe", encoding: "utf8" });
42
+ } catch (e) {
43
+ sOk = false;
44
+ lines.push(`❌ 语法 ${f}`);
45
+ lines.push(String(e.stderr || e.message).slice(-300));
46
+ }
47
+ }
48
+ if (sOk) lines.push(`✅ 语法(${files.length} 个 lib 文件)`);
49
+
50
+ // ── ② 单元 ──
51
+ step("单元(test/run-all.mjs)", process.execPath, [join(root, "test", "run-all.mjs")]);
52
+
53
+ // ── ③ 组合冒烟 ──
54
+ step("组合冒烟(loader-smoke,真实接线+外部世界断言)", process.execPath, [join(root, "test", "loader-smoke.e2e.mjs")]);
55
+
56
+ // ── ④ 真实判例(外部世界) ──
57
+ let judgeReal = 0;
58
+ const auditPath = join(process.env.DSH_HOME || join(process.env.USERPROFILE || "", ".dsh"), "rule-engine.log.jsonl");
59
+ const since = Date.now() - 24 * 3600 * 1000;
60
+ try {
61
+ for (const line of readFileSync(auditPath, "utf8").split("\n")) {
62
+ if (!line.includes("judge-pass") && !line.includes("judge-false")) continue;
63
+ const m = line.match(/"ts":"([^"]+)"/);
64
+ if (m && new Date(m[1]).getTime() >= since) judgeReal++;
65
+ }
66
+ } catch {
67
+ // 无日志:提示即可
68
+ }
69
+ if (judgeReal > 0) {
70
+ lines.push(`✅ 真实判例(近 24h judge-pass/judge-false = ${judgeReal} 条——裁决器真实工作证据)`);
71
+ } else {
72
+ lines.push("⚠️ 真实判例:近 24h 无 judge-pass/judge-false 记录——裁决器需要一次实弹(非阻塞 WARN,但说明运行证据尚未产生)");
73
+ }
74
+
75
+ console.log(lines.join("\n"));
76
+ const failed = lines.filter((l) => l.startsWith("❌"));
77
+ if (failed.length) {
78
+ console.log(`\nVERIFY FAIL:${failed.length} 项问题,见上`);
79
+ process.exit(1);
80
+ }
81
+ console.log("\nVERIFY ALL OK");