dsh-rule-engine 0.6.2 → 0.6.3

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.
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { isAbsolute } from "node:path";
6
+ import { getMessage } from "../messages.js";
6
7
 
7
8
  export const INLINE_CMD =
8
9
  /\b(?:node|pwsh|powershell)\s+(?:-[ep]|--eval|--print|-Command|-c)\b/i;
@@ -28,21 +29,18 @@ export const PROTECTED_FILENAME_RE =
28
29
  export const DATA_DIR_RE =
29
30
  /(?:^|[\\/])\.dsh[\\/](?:sessions|storages|\.backups)[\\/]/i;
30
31
 
31
- /** 当下时间词(规则 2①:写当下相对时间前必须先 Get-Date 核对——A1 拆组,2026-09-03) */
32
- export const TIME_WORDS =
33
- /今天|昨天|前天|上周|本周|刚才|\d+\s*分钟前/;
32
+ /** 当下时间词(规则 2①:写当下相对时间前必须先 Get-Date 核对——A1 拆组,2026-09-03)
33
+ * P8 小批 B(2026-09-08):词表下沉配置,经 patRe("time_words") 取(见文件末尾配置层) */
34
+ export const timeWordsRe = () => patRe("time_words");
34
35
 
35
36
  /** 历史日期/绝对日期(规则 2②:只要求事件证据锚,不要求 Get-Date——A1 拆组;含 ISO 格式,本机高频表述) */
36
- export const HISTORIC_DATE_RE =
37
- /\d{1,2}\s*月\s*\d{1,2}\s*日|\d{4}\s*年\s*\d{1,2}\s*月\s*\d{1,2}\s*日|20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/;
37
+ export const historicDateRe = () => patRe("historic_date");
38
38
 
39
39
  /** 事件时间证据标注(规则 2②:过去事件时间必须绑定事件自身证据;"之前/当时"等模糊词不触发检测、不采信)
40
40
  * A1 增补证据锚(2026-09-03):commit hash / 版本号 / 踩坑 N / 版本记录 vX —— 历史日期带锚即视为已标注来源,免 Get-Date */
41
- export const EVIDENCE_MARK_RE =
42
- /日志\s*(?:ts|时间戳)|mtime|启动时间|进程\s*StartTime|文件\s*修改时间|来源[::]|ts\s*[:=]|Get-Date\s*输出|事件时间已核实|【时间未核实】|\b[0-9a-f]{7,40}\b|\bv\d+(?:\.\d+){1,2}\b|踩坑\s*\d+|版本(?:记录)?\s*v\d+(?:\.\d+){1,2}/i;
41
+ export const evidenceMarkRe = () => patRe("evidence_mark");
43
42
 
44
- export const PROMISE_WORDS =
45
- /包在我身上|肯定能|绝对没问题|保证(?!不|无法)|一定可以|放心(?:,|,)?肯定|万无一失/;
43
+ export const promiseWordsRe = () => patRe("promise_words");
46
44
 
47
45
  export const URL_RE = /https?:\/\/[^\s]+/i;
48
46
 
@@ -50,12 +48,11 @@ export const URL_RE = /https?:\/\/[^\s]+/i;
50
48
  export const PRIVATE_NETWORK_RE =
51
49
  /(?:127\.0\.0\.1|localhost|169\.254\.169\.254|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})/i;
52
50
 
53
- export const SOURCE_MARK = /来源|出处|via|source|reference|引自|参考/i;
51
+ export const sourceMarkRe = () => patRe("source_mark");
54
52
 
55
53
  export const CJK_RE = /[\u4e00-\u9fff]/;
56
54
 
57
- export const DSH_KEYWORDS_RE =
58
- /DSH|dsh|插件|技能|规则|配置|迁移|手册|会话|装配|profile|bundle/i;
55
+ export const dshKeywordsRe = () => patRe("domain_words");
59
56
 
60
57
  export const SELF_PROTECT_PATHS = [
61
58
  "**/rule-engine.json",
@@ -63,21 +60,36 @@ export const SELF_PROTECT_PATHS = [
63
60
  "**/rule-guard.json"
64
61
  ];
65
62
 
63
+ /** 路径规范化(纯字符串,不触盘):去包裹引号 → 统一分隔符 → 折叠重复分隔符 → 解析 . / .. → 小写。
64
+ * 用于本机受保护路径的「包含比较」:防 `\\`、`.`、`..` 等写法变体绕过守卫(2026-09-08 实测缺口)。 */
65
+ export function canonicalPath(p) {
66
+ if (typeof p !== "string") return "";
67
+ const s = p.replace(/^["']+|["']+$/g, "").replace(/\\/g, "/").replace(/\/{2,}/g, "/");
68
+ const out = [];
69
+ for (const seg of s.split("/")) {
70
+ if (seg === ".") continue;
71
+ if (seg === "..") { if (out.length > 1) out.pop(); continue; }
72
+ out.push(seg);
73
+ }
74
+ return out.join("/").toLowerCase();
75
+ }
76
+
66
77
  /** 判断一条命令文本是否是「读取手册」类命令(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
67
78
  export function isManualReadCommand(command, manualPaths = []) {
68
79
  if (typeof command !== "string") return false;
69
80
  if (!Array.isArray(manualPaths)) return false;
70
- const hit = manualPaths.some((pp) => command.toLowerCase().includes(String(pp).toLowerCase()));
81
+ const n = canonicalPath(command);
82
+ const hit = manualPaths.some((pp) => n.includes(canonicalPath(String(pp))));
71
83
  return hit && /get-content|cat|type|read|grep|findstr|str_replace_editor/i.test(command);
72
84
  }
73
85
 
74
- /** 本机追加文件路径匹配(A2-2 算法):相对 DSH_HOME 归一化 + 大小写不敏感 + 包含比较。
75
- * manualPaths 为空 = 无本机配置 = 恒 false(守卫无对象,自然静默)。 */
86
+ /** 本机追加文件路径匹配(A2-2 算法):规范化 + 大小写不敏感 + 包含比较。
87
+ * manualPaths 为空 = 无本机配置 = 恒 false(守卫无对象,自然静默)。
88
+ * 2026-09-08:改用 canonicalPath——原实现只做斜杠替换,`\\` / `.` / `..` 写法变体可绕过守卫。 */
76
89
  export function matchManualPath(p, manualPaths = []) {
77
90
  if (typeof p !== "string" || !Array.isArray(manualPaths)) return false;
78
- const norm = (s) => s.replace(/\\/g, "/").replace(/^["']+|["']+$/g, "").toLowerCase();
79
- const n = norm(p);
80
- return manualPaths.some((pp) => n.includes(norm(String(pp))));
91
+ const n = canonicalPath(p);
92
+ return manualPaths.some((pp) => n.includes(canonicalPath(String(pp))));
81
93
  }
82
94
 
83
95
  /** 判断一次工具调用是否算作「已读手册」(manualPaths = 本机配置的手册路径列表;无配置 = 无对象,恒 false) */
@@ -423,17 +435,20 @@ export function isAnalysisOp(toolName, args) {
423
435
 
424
436
  // ═══════════ 0.5.10 已知环境坑特征表(建议5——错误码自动召回,K-01/K-05 依据)═══════════
425
437
  // 工具/命令错误文本命中特征 → 审计 error-hint + 注入指向知识库的提示(低频:仅命中才提示;语义=指路不打扰)。
438
+ // hint 文案经 messages 层取(第 1 批 1b:去本机标识——原 hint 引用本机手册踩坑编号/代理端口/发布变量)
426
439
  const KNOWN_PITFALLS = [
427
- { key: "SEC_E_NO_CREDENTIALS", re: /SEC_E_NO_CREDENTIALS/i, hint: "TLS 凭据失败(常见于沙箱/受限会话)——先查手册踩坑 60/89;Node fetch 配 NODE_USE_ENV_PROXY 可绕" },
428
- { key: "proxy-7890-refused", re: /ECONNREFUSED[^\n]*7890/i, hint: "代理端口 7890 无监听——确认代理软件已启动;git/gh 可直连,npm registry 直连本机不可用(发布脚本默认直连,DSH_RELEASE_PROXY 可指定)" },
429
- { key: "sandbox-pipe-EPERM", re: /EPERM/i, hint: "受限沙箱下子进程管道受限(踩坑 22⑤)——需 danger-full-access 运行" },
430
- { key: "ERR_MODULE_NOT_FOUND", re: /ERR_MODULE_NOT_FOUND/i, hint: "模块缺失——检查依赖闭包/空壳目录(踩坑 54)" }
440
+ { key: "SEC_E_NO_CREDENTIALS", re: /SEC_E_NO_CREDENTIALS/i, hintKey: "pitfall.tls" },
441
+ { key: "proxy-7890-refused", re: /ECONNREFUSED[^\n]*7890/i, hintKey: "pitfall.proxy" },
442
+ { key: "sandbox-pipe-EPERM", re: /EPERM/i, hintKey: "pitfall.sandbox-pipe" },
443
+ { key: "ERR_MODULE_NOT_FOUND", re: /ERR_MODULE_NOT_FOUND/i, hintKey: "pitfall.module-missing" }
431
444
  ];
432
445
 
433
446
  /** 已知坑特征匹配(纯函数):错误文本命中特征表 → 返回条目;未命中 → null */
434
447
  export function matchKnownPitfall(text) {
435
448
  const t = String(text || "");
436
- for (const p of KNOWN_PITFALLS) if (p.re.test(t)) return p;
449
+ for (const p of KNOWN_PITFALLS) {
450
+ if (p.re.test(t)) return { key: p.key, hint: getMessage(p.hintKey) };
451
+ }
437
452
  return null;
438
453
  }
439
454
 
@@ -458,17 +473,17 @@ export function isDangerousInlineNode(code) {
458
473
  * 2026-09-06 M7 修复:opts.askApproved(本回合已获 ask 授权答复)→ 豁免——ask 答复后同一任务落盘
459
474
  * 不再被保守误报 approval-gap(12A 授权链已由 ask 建立,M7 不重复提醒)。
460
475
  */
461
- const PLAN_INSTRUCTION_RE = /方案|调整|补充|建议|评估|草案|完善|优化|改进|提炼|重构|梳理/;
462
- const WRITE_INSTRUCTION_RE = /落盘|写入|发布|正式写入|正式落盘|改为|改成|保存到手册|写进手册|确定为|确认后(?:落盘|写入|发布)/;
476
+ const planInstructionRe = () => patRe("plan_instruction");
477
+ const writeInstructionRe = () => patRe("write_instruction");
463
478
  // 2026-09-06 0.6.x:建议+执行并存("按第三方的建议按顺序执行修改")=执行分点——执行词豁免(M7 误报修复)
464
- const EXECUTE_ACTION_RE = /执行|推进|实施|开始|落实|继续|启动|办理|开展|落地|操作|运行/;
479
+ const executeActionRe = () => patRe("execute_action");
465
480
  export function needsApprovalReminder(userText, opts = {}) {
466
481
  const t = String(userText || "");
467
482
  if (!t) return false;
468
483
  if (opts.askApproved) return false; // M7 修复:ask 授权答复豁免 approval-gap
469
- if (!PLAN_INSTRUCTION_RE.test(t)) return false;
470
- if (EXECUTE_ACTION_RE.test(t)) return false; // 0.6.x:方案词+执行词并存=执行指令("按建议执行"含执行语)
471
- return !WRITE_INSTRUCTION_RE.test(t);
484
+ if (!planInstructionRe().test(t)) return false;
485
+ if (executeActionRe().test(t)) return false; // 0.6.x:方案词+执行词并存=执行指令("按建议执行"含执行语)
486
+ return !writeInstructionRe().test(t);
472
487
  }
473
488
 
474
489
  let workspaceRoots = []; // 全局注册的工作区根(兜底层)
@@ -684,16 +699,42 @@ export function isSensitiveToolCall(toolName, args, sessionId) {
684
699
 
685
700
  // ── 批次 5 文本健康检测词表(2026-08-24) ──────────────────────────────
686
701
  /** 技术术语(规则 11③ 零基础表达:中文提问 + 术语 + 无解释 → 自证)。英文词带 \b,中文词组按字面。 */
687
- export const TECH_TERM_RE =
688
- /\b(?:API|JSON|REST|WebSocket|OAuth|JWT|ORM|Schema|SSR|CSR|DI|Docker|Kubernetes|K8s|npm|pnpm|Node\.js|TypeScript|Git)\b|正则(?:表达|表达式)?|异步|回调|闭包|哈希|令牌|中间件|依赖注入|虚拟DOM|虚拟 DOM|数据库|SQL|序列化|反序列化|面向对象|函数式|类型推断|泛型/i;
702
+ export const techTermRe = () => patRe("tech_term");
689
703
 
690
704
  /** 术语解释伴随词(出现任一 → 视为已解释,不触发密度自证) */
691
- export const TERM_EXPLANATION_RE =
692
- /例如|比如|即\b|就是|简单说|换言之|类比|也就是说|通俗|直白|理解为|打个比方|举个例子/;
705
+ export const termExplanationRe = () => patRe("term_explanation");
693
706
 
694
707
  /** 建议类表述(规则 16 重复推销:会话内同类建议 ≥2 → 自证) */
695
- export const SUGGEST_RE =
696
- /我建议|建议用|推荐|更优方案|建议(?:是|考虑|换成|用|来)|档位建议|不如换|优化建议/;
708
+ export const suggestRe = () => patRe("suggest_words");
709
+
710
+ // A″ 批评检测形态(P8 小批 C):强形态(连续问号/叹号)+ 弱形态(反问/责问)
711
+ export const criticismShapeRe = () => patRe("criticism_shape");
712
+ export const criticismWeakRe = () => patRe("criticism_weak");
713
+ // A″ 反向检查(第三批):明确执行指令词
714
+ export const execDirectiveRe = () => patRe("exec_directive");
715
+ // 规则激活词(小批 B 第一小域):matcher.js 的情境匹配机词表
716
+ export const activateSkillRe = () => patRe("activate_skill");
717
+ export const activateApprovalRe = () => patRe("activate_approval");
718
+ export const activateNetworkRe = () => patRe("activate_network");
719
+ export const activateBackupRe = () => patRe("activate_backup");
720
+ export const activateTimeRe = () => patRe("activate_time");
721
+ export const activatePromiseRe = () => patRe("activate_promise");
722
+ export const activateSourceRe = () => patRe("activate_source");
723
+ export const deliveryClaimRe = () => patRe("delivery_claim");
724
+ export const internalRefRe = () => patRe("internal_ref");
725
+ export const verifyIntentRe = () => patRe("verify_intent");
726
+ export const emptyTalkRe = () => patRe("empty_talk");
727
+ export const deliveryNoVerifyRe = () => patRe("delivery_no_verify");
728
+ export const verifyEvidenceRe = () => patRe("verify_evidence");
729
+ export const mountMentionRe = () => patRe("mount_mention");
730
+ export const mountAuditOkRe = () => patRe("mount_audit_ok");
731
+ export const scopeOverreachRe = () => patRe("scope_overreach");
732
+ export const scopeBoundedRe = () => patRe("scope_bounded");
733
+ export const apologyOnlyRe = () => patRe("apology_only");
734
+ export const apologyWithCauseRe = () => patRe("apology_with_cause");
735
+ export const versionRecordMentionRe = () => patRe("version_record_mention");
736
+ export const versionRecordOnlyRe = () => patRe("version_record_only");
737
+ export const versionSyncOkRe = () => patRe("version_sync_ok");
697
738
 
698
739
  /**
699
740
  * v0.5.7 P0.5-6(用户拍板):否定/合规声明语境("不再/避免/停止/无 XX")——那不是重复推销。
@@ -702,7 +743,7 @@ export const SUGGEST_RE =
702
743
  */
703
744
  export function isNegatingSuggestion(text) {
704
745
  if (typeof text !== "string") return false;
705
- return /(?:不再|不重复|避免|停止|取消|暂不|未再|不要(?:再)?|以后(?:不|别)|今后(?:不|别)|已呼应|已回应|无(?:新)?(?:建议|重复)|没有(?:新)?(?:建议|重复)|只等待)/.test(text);
746
+ return patRe("negating_suggestion").test(text);
706
747
  }
707
748
 
708
749
  /**
@@ -714,10 +755,11 @@ export function isNegatingSuggestion(text) {
714
755
  * 修复背景:B3 规则 7 仅认引号+白名单标记词,无引号转述("用户之前说万无一失")误报;
715
756
  * 规则 2 原本无引述豁免(引述用户原话"昨天"也报未核对)。
716
757
  */
717
- const PARAPHRASE_BEFORE_RE =
718
- /(?:用户|你|他|她|它|对方|作者|维护者|客户|大家|网友|某人|群友|别人).{0,6}?(?:说|说过|表示|提到|提到过|称|强调|认为|写道|原话)/;
719
- const PARAPHRASE_MARK_BEFORE_RE = /引述|引用|原文|转述|转发|据\S{0,5}说|写过|改成|改为|换成/;
720
- const PARAPHRASE_MARK_AFTER_RE = /改成|改为|换成|转述|引用/;
758
+ const paraphraseBeforeRe = () => patRe("paraphrase_before");
759
+ const paraphraseMarkBeforeRe = () => patRe("paraphrase_mark_before");
760
+ const paraphraseMarkAfterRe = () => patRe("paraphrase_mark_after");
761
+ // 引号字符集(语言无关:ASCII + 中文弯引号)——不是行为词表,留在机制层
762
+ const QUOTE_CHARS_RE = /[“"「『‘'””」』’]/;
721
763
 
722
764
  export function isQuoteOrParaphraseContext(text, wordRe) {
723
765
  if (typeof text !== "string" || !(wordRe instanceof RegExp)) return false;
@@ -728,17 +770,273 @@ export function isQuoteOrParaphraseContext(text, wordRe) {
728
770
  const after = text.slice(m.index + m[0].length);
729
771
  // 引述包装 = 承诺词两侧 8 字符内都有引号(ASCII/中文弯引号均算;不区分左右——
730
772
  // ASCII 单引号左右同形,分区判定会自相矛盾;双侧近邻引号即视为被引述包裹)。
731
- const openNear = /[“"「『‘'””」』’]/.test(before.slice(-8));
732
- const closeNear = /[“"「『‘'””」』’]/.test(after.slice(0, 8));
773
+ const openNear = QUOTE_CHARS_RE.test(before.slice(-8));
774
+ const closeNear = QUOTE_CHARS_RE.test(after.slice(0, 8));
733
775
  if (openNear && closeNear) return true;
734
- if (PARAPHRASE_BEFORE_RE.test(before.slice(-14))) return true;
735
- if (PARAPHRASE_MARK_BEFORE_RE.test(before.slice(-12))) return true;
736
- if (PARAPHRASE_MARK_AFTER_RE.test(after.slice(0, 12))) return true;
776
+ if (paraphraseBeforeRe().test(before.slice(-14))) return true;
777
+ if (paraphraseMarkBeforeRe().test(before.slice(-12))) return true;
778
+ if (paraphraseMarkAfterRe().test(after.slice(0, 12))) return true;
737
779
  }
738
780
  return false;
739
781
  }
740
782
 
741
783
  /** v0.5.7 P0.5-5(用户拍板)+ B3 扩展:承诺词处于引述/改写/转述语境 → 是引述不是承诺 */
742
784
  export function isPromiseQuoteContext(text) {
743
- return isQuoteOrParaphraseContext(text, PROMISE_WORDS);
785
+ return isQuoteOrParaphraseContext(text, promiseWordsRe());
786
+ }
787
+
788
+ // ═══════════ P8 小批 B(2026-09-08):检测正则配置层 ═══════════
789
+ // 双层模型(同 lexicon.js):
790
+ // ① 通用层 BUILTIN_PATTERNS —— 语言无关最小集,随 npm 包发布,不含中文;
791
+ // ② 个人层 rule-engine.json 的 patterns 键 —— undefined/null = 不干预(保持当前);
792
+ // 对象 = 按键完全替换(Override),未提供的键回退内置默认。
793
+ // 取词一律走 patRe(key)(带缓存);禁止在调用方缓存正则对象(配置注入后旧引用不更新)。
794
+ //
795
+ // 安全边界:本层只承载「文本健康检测」类正则(规则 2/5/7/11/16 的检测词表);
796
+ // 命令判定/路径守卫类正则(MUTATING_CMD_RE / READONLY_CMD_RE / CONFIG_FILE_RE 等)
797
+ // 一律留在机制层且不含中文——配置缺失不会削弱硬守卫。
798
+
799
+ /** 可配置检测正则键名(键 → 语义) */
800
+ export const PATTERN_KEYS = [
801
+ "time_words", // 规则 2① 当下时间词
802
+ "historic_date", // 规则 2② 历史/绝对日期
803
+ "evidence_mark", // 规则 2② 事件时间证据锚
804
+ "promise_words", // 规则 7 承诺/大话术词
805
+ "source_mark", // 规则 5 来源引用标记
806
+ "domain_words", // 规则 18 DSH 领域词
807
+ "plan_instruction", // M7 方案性指令词
808
+ "write_instruction", // M7 落盘性词(豁免提醒)
809
+ "execute_action", // M7 执行动作词(豁免提醒)
810
+ "tech_term", // 规则 11③ 技术术语
811
+ "term_explanation", // 规则 11③ 术语解释伴随词
812
+ "suggest_words", // 规则 16 建议类表述
813
+ "negating_suggestion", // 规则 16 否定/合规声明语境
814
+ "paraphrase_before", // 转述语境(第三人称 + 说/表示…)
815
+ "paraphrase_mark_before", // 引述标记(前)
816
+ "paraphrase_mark_after", // 引述标记(后)
817
+ "criticism_shape", // 规则 22② A″ 批评强形态(连续问号/叹号)
818
+ "criticism_weak", // 规则 22② A″ 批评弱形态(反问/责问)
819
+ "exec_directive", // A″ 反向检查:消息含明确执行指令词(弱信号仅留痕不提示)
820
+ "activate_skill", // 规则 12B 激活词
821
+ "activate_approval", // 规则 12A 激活词
822
+ "activate_network", // 规则 12C 激活词
823
+ "activate_backup", // 规则 13A 激活词
824
+ "activate_time", // 规则 2 激活词
825
+ "activate_promise", // 规则 7 激活词
826
+ "activate_source", // 规则 5 激活词
827
+ "delivery_claim", // 检测正则(小批 B 第一小域)
828
+ "internal_ref", // 检测正则(小批 B 第一小域)
829
+ "verify_intent", // 检测正则(小批 B 第一小域)
830
+ "empty_talk", // 检测正则(小批 B 第一小域)
831
+ "delivery_no_verify", // 检测正则(小批 B 第一小域)
832
+ "verify_evidence", // 检测正则(小批 B 第一小域)
833
+ "mount_mention", // 检测正则(小批 B 第一小域)
834
+ "mount_audit_ok", // 检测正则(小批 B 第一小域)
835
+ "scope_overreach", // 检测正则(小批 B 第一小域)
836
+ "scope_bounded", // 检测正则(小批 B 第一小域)
837
+ "apology_only", // 检测正则(小批 B 第一小域)
838
+ "apology_with_cause", // 检测正则(小批 B 第一小域)
839
+ "version_record_mention", // 检测正则(小批 B 第一小域)
840
+ "version_record_only", // 检测正则(小批 B 第一小域)
841
+ "version_sync_ok", // 检测正则(小批 B 第一小域)
842
+ ];
843
+
844
+ /**
845
+ * 映射型配置键(键 → { 子键: source }):一张表多条正则的场景(如条款自证触发词)。
846
+ * 子键语义由消费方定义;与扁平正则键共用同一配置层与回退语义。
847
+ */
848
+ export const PATTERN_MAP_KEYS = {
849
+ // 内置默认**空表**(判据 A,2026-09-09):键=规则号,属发布者私有的规则体系,
850
+ // 一律从 rule-engine.json 的 patterns.self_cert_hints 注入;空表时该机制静默不触发。
851
+ self_cert_hints: {}
852
+ };
853
+
854
+ /** 数值型配置键(键 → 内置默认;与正则键共用同一配置层与校验/回退语义) */
855
+ export const PATTERN_NUM_KEYS = {
856
+ criticism_caps_ratio: 0.6 // 英文全大写比率阈值(≥ 该值且字母数 ≥6 视为"喊叫"强形态)
857
+ };
858
+
859
+ // 内置默认:语言无关最小集(无中文)
860
+ const BUILTIN_PATTERNS = {
861
+ time_words: "(?:today|yesterday|just now|a moment ago|\\d+\\s*minutes? ago)",
862
+ historic_date: "20\\d{2}[-/.]\\d{1,2}[-/.]\\d{1,2}",
863
+ evidence_mark: "mtime|ts\\s*[:=]|\\b[0-9a-f]{7,40}\\b|\\bv\\d+(?:\\.\\d+){1,2}\\b",
864
+ promise_words: "(?:i promise|guaranteed|absolutely (?:fine|no problem)|100% sure|no way it fails)",
865
+ source_mark: "via|source|reference|quoted from",
866
+ domain_words: "DSH|dsh|profile|bundle|plugin|skill|config|migrat|manual|session|assembly",
867
+ plan_instruction: "plan|proposal|adjust|supplement|suggest|evaluat|draft|refine|optimi[sz]e|improve|restructure|review",
868
+ write_instruction: "persist|write|publish|save to manual|confirm(?:ed)?",
869
+ execute_action: "execute|run|implement|start|apply|continue|proceed|operate",
870
+ tech_term:
871
+ "\\b(?:API|JSON|REST|WebSocket|OAuth|JWT|ORM|Schema|SSR|CSR|DI|Docker|Kubernetes|K8s|npm|pnpm|Node\\.js|TypeScript|Git)\\b|regex|async|callback|closure|hash|token|middleware|dependency injection|virtual DOM|database|SQL|serializ|deserializ|object-oriented|functional|type inference|generic",
872
+ term_explanation: "e\\.g\\.|for example|i\\.e\\.|in other words|analogy|plainly|understand (?:it )?as|for instance",
873
+ suggest_words: "i suggest|recommend|better option|optimi[sz]ation suggestion|how about",
874
+ negating_suggestion:
875
+ "(?:no longer|not repeat|avoid|stop|cancel|not yet|never again|already (?:echoed|responded)|no new (?:suggestion|repetition)|just waiting)",
876
+ paraphrase_before:
877
+ "(?:user|you|he|she|it|they|author|maintainer|client|customer|everyone|someone|netizen|colleague).{0,6}?(?:said|mentioned|stated|noted|claimed|emphasi[sz]ed|wrote|verbatim)",
878
+ paraphrase_mark_before: "quote|quoted|original text|paraphras|forward|according to|wrote|changed to|reword",
879
+ paraphrase_mark_after: "changed to|reword|paraphras|quote",
880
+ // A″ 强形态:连续问号/叹号(全角半角均计;标点非 CJK 汉字,故属语言无关形态)
881
+ criticism_shape: "(?:[??]{3,}|[!!]{4,})",
882
+ // A″ 弱形态:英文最小集(中文反问形态由本机 patterns 注入)
883
+ criticism_weak:
884
+ "(?:why (?:are you |do you )?(?:still|again|always)|you (?:again|always|never)|this is (?:wrong|not right|unacceptable)|what (?:are you|the hell))",
885
+ // A″ 反向检查(2026-09-09 第三批):消息含明确执行指令词 → 弱信号只留痕不注入提示
886
+ exec_directive:
887
+ "(?:write|update|execute|submit|persist|apply|implement|fix|modify|create|delete|publish|download|commit|push|run|refactor|rewrite)",
888
+ // 规则激活词(P8 小批 B 第一小域,2026-09-09):matcher.js 用
889
+ activate_skill: "(?:skill|plugin)",
890
+ activate_approval: "(?:execute|create|delete|overwrite|move|download|commit|config)",
891
+ activate_network: "(?:download|network|curl|proxy|blocked)",
892
+ activate_backup: "(?:delete|overwrite|migrate|backup)",
893
+ activate_time: "(?:time|today|yesterday|date)",
894
+ activate_promise: "(?:promise|guarantee|certainly)",
895
+ activate_source: "(?:quote|source|url|link)",
896
+ delivery_claim: "(?:done|completed|all (?:done|passed)|fixed|verified|finished)(?![^.]*(?:not yet|pending|incomplete|todo))",
897
+ internal_ref: "(?:manual|pitfall\\s*\\d+|clause\\s*\\d+|chapter\\s*\\d+|SKILL\\.md|AGENTS\\.md|source code|README|docs?\\s+\\w*(?:written|recorded|documented))",
898
+ verify_intent: "(?:verify|validate|cross-check|target|compare|confirm)",
899
+ empty_talk: "(?:got it|noted|i'?ll remember|sure, i'?ve noted)",
900
+ delivery_no_verify: "(?:done|delivered|completed)",
901
+ verify_evidence: "(?:runtime verif|mock|startup|actual test|test|verif)",
902
+ mount_mention: "(?:restart|assembly|mount)",
903
+ mount_audit_ok: "(?:audit passed|full audit|MOUNT CONSISTENT|DUPLICATES FOUND|audit-mount-consistency)",
904
+ scope_overreach: "(?:i (?:also |additionally )?(?:added|supplemented|included)|extra add)",
905
+ scope_bounded: "(?:only (?:the )?selected|not implemented|separately confirm)",
906
+ apology_only: "(?:sorry|my mistake|my fault|i was wrong)",
907
+ apology_with_cause: "(?:cause|reason|correction|prevent|avoid|mechanism|fix)",
908
+ version_record_mention: "(?:changelog|version record|v\\d+\\.\\d+)",
909
+ version_record_only: "(?:logged (?:to )?changelog|changelog updated)",
910
+ version_sync_ok: "(?:body|sync|no sync needed)"
911
+ };
912
+
913
+ let patternsOverride = null;
914
+ let patCache = new Map();
915
+
916
+ function compilePattern(source, flags) {
917
+ if (typeof source !== "string" || source.length === 0) return null;
918
+ try {
919
+ return new RegExp(source, flags);
920
+ } catch {
921
+ return null;
922
+ }
923
+ }
924
+
925
+ function resolvePatternSource(key) {
926
+ const ov = patternsOverride?.[key];
927
+ if (typeof ov === "string" && ov.length > 0) return ov;
928
+ return BUILTIN_PATTERNS[key] ?? "";
929
+ }
930
+
931
+ /** 数值型键的生效值(个人层优先 → 内置默认) */
932
+ /** 取当前生效的映射型配置(个人层优先 → 内置默认);无对象 = 空表 */
933
+ export function patMap(key) {
934
+ const ov = patternsOverride?.[key];
935
+ if (ov && typeof ov === "object" && !Array.isArray(ov)) return ov;
936
+ return PATTERN_MAP_KEYS[key] ?? {};
937
+ }
938
+
939
+ /** 数值型键的生效值(个人层优先 → 内置默认) */
940
+ export function patNum(key) {
941
+ const ov = patternsOverride?.[key];
942
+ if (typeof ov === "number" && Number.isFinite(ov)) return ov;
943
+ return PATTERN_NUM_KEYS[key] ?? 0;
944
+ }
945
+
946
+ /**
947
+ * 注入个人层检测正则(rule-engine.json 的 patterns 键)。
948
+ * 语义同 setLexicons:undefined/null = 不干预;对象 = 配置即真相;`{}` = 回退内置默认。
949
+ * @returns {{applied: string[], rejected: Array<{key: string, reason: string}>, noop?: boolean}}
950
+ */
951
+ export function setPatterns(cfg) {
952
+ const applied = [];
953
+ const rejected = [];
954
+ if (cfg === undefined || cfg === null) return { applied, rejected, noop: true };
955
+ if (typeof cfg !== "object" || Array.isArray(cfg)) {
956
+ patternsOverride = null;
957
+ patCache = new Map();
958
+ return { applied, rejected };
959
+ }
960
+ const next = {};
961
+ for (const [k, v] of Object.entries(cfg)) {
962
+ // 映射型键(值 = 对象)
963
+ if (PATTERN_MAP_KEYS[k] !== undefined) {
964
+ if (!v || typeof v !== "object" || Array.isArray(v)) {
965
+ rejected.push({ key: k, reason: "not-object" });
966
+ continue;
967
+ }
968
+ const sub = {};
969
+ for (const [sk, sv] of Object.entries(v)) {
970
+ if (typeof sv !== "string" || !compilePattern(sv, "i")) {
971
+ rejected.push({ key: `${k}.${sk}`, reason: "invalid-regex" });
972
+ continue;
973
+ }
974
+ sub[sk] = sv;
975
+ }
976
+ if (Object.keys(sub).length > 0) {
977
+ next[k] = sub;
978
+ applied.push(k);
979
+ }
980
+ continue;
981
+ }
982
+ // 数值型键(阈值):0..1 的有限数;字符串数字也接受(JSON 手写容错)
983
+ if (PATTERN_NUM_KEYS[k] !== undefined) {
984
+ const num = typeof v === "number" ? v : Number(v);
985
+ if (!Number.isFinite(num) || num < 0 || num > 1) {
986
+ rejected.push({ key: k, reason: "invalid-number" });
987
+ continue;
988
+ }
989
+ next[k] = num;
990
+ applied.push(k);
991
+ continue;
992
+ }
993
+ if (!PATTERN_KEYS.includes(k)) {
994
+ rejected.push({ key: k, reason: "unknown-key" });
995
+ continue;
996
+ }
997
+ if (typeof v !== "string" || v.length === 0) {
998
+ rejected.push({ key: k, reason: "empty-or-not-string" });
999
+ continue;
1000
+ }
1001
+ if (!compilePattern(v, "i")) {
1002
+ rejected.push({ key: k, reason: "invalid-regex" });
1003
+ continue;
1004
+ }
1005
+ next[k] = v;
1006
+ applied.push(k);
1007
+ }
1008
+ patternsOverride = Object.keys(next).length > 0 ? next : null;
1009
+ patCache = new Map();
1010
+ return { applied, rejected };
1011
+ }
1012
+
1013
+ /** 显式回退内置默认(测试用;生产环境改配置后由插件重载生效) */
1014
+ export function resetPatterns() {
1015
+ patternsOverride = null;
1016
+ patCache = new Map();
1017
+ }
1018
+
1019
+ /** 当前生效的正则 source 快照(键 → source;数值键 → 数值;只读,供测试/诊断) */
1020
+ export function effectivePatterns() {
1021
+ const out = {};
1022
+ for (const k of PATTERN_KEYS) out[k] = resolvePatternSource(k);
1023
+ for (const k of Object.keys(PATTERN_NUM_KEYS)) out[k] = patNum(k);
1024
+ return out;
1025
+ }
1026
+
1027
+ /** 当前是否处于个人层覆盖状态 */
1028
+ export function hasPatternOverride() {
1029
+ return patternsOverride !== null;
1030
+ }
1031
+
1032
+ /** 取当前生效正则(唯一取词入口;调用方禁止缓存返回对象)。非法/空 source → 永不命中占位。 */
1033
+ export function patRe(key) {
1034
+ const source = resolvePatternSource(key);
1035
+ const ck = `${key}\u0000${source}`;
1036
+ let hit = patCache.get(ck);
1037
+ if (!hit) {
1038
+ hit = compilePattern(source, "i") || /(?!)/;
1039
+ patCache.set(ck, hit);
1040
+ }
1041
+ return hit;
744
1042
  }
@@ -0,0 +1,124 @@
1
+ // quality-ledger.js - 质量账本(机制进发布面,数据留本机,签名不外泄)
2
+ //
3
+ // 设计原则(第三批方案 §一):
4
+ // · 机制层(本文件,随包发布):taskSignature / recordQuality / qualityTrend —— 每个安装者可用;
5
+ // · 数据层(~/.dsh/quality-ledger.jsonl,**永不发布**):每单一条记录;
6
+ // · 配置层(rule-engine.json 的 qualityLedger 键,默认 enabled:false):不开不产生任何文件;
7
+ // · 隐私:落盘的是 sha256 归一化指纹(12 位),**单向不可逆**——看到 jsonl 也只知道
8
+ // "有个指纹的任务做过",不知道任务内容。
9
+ //
10
+ // 不做的事:不拦截、不评分、不上传(纯旁路统计)。
11
+ import { createHash } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import os from "node:os";
14
+ import path from "node:path";
15
+
16
+ /** 默认配置(可被 rule-engine.json 的 qualityLedger 覆盖) */
17
+ export const DEFAULT_LEDGER_CONFIG = {
18
+ enabled: false, // 默认关——不开不产生任何文件
19
+ window: 5, // 趋势窗口(最近 N 单 vs 之前 N 单)
20
+ z: 1.96 // 预留:二期 Wilson 区间用
21
+ };
22
+
23
+ /** 账本文件路径(本机数据,永不发布) */
24
+ export function ledgerPath(home) {
25
+ const base = home || process.env.DSH_HOME || path.join(os.homedir(), ".dsh");
26
+ return path.join(base, "quality-ledger.jsonl");
27
+ }
28
+
29
+ /**
30
+ * 任务签名:对 purpose + 验收断言做**归一化**后取 sha256 前 12 位。
31
+ * 归一化规则(公开可审计):去引号 → 绝对路径替换为 <path> → 数字替换为 <n> → 折叠空白 → 小写。
32
+ * 目的:同一类任务(措辞/数字/路径不同)得到同一签名,从而能统计"同类任务返工率"。
33
+ */
34
+ export function taskSignature(purpose, assertions = []) {
35
+ const parts = [String(purpose || ""), ...(Array.isArray(assertions) ? assertions : [assertions])];
36
+ const norm = parts
37
+ .join("\n")
38
+ .replace(/["'`「」『』]/g, "")
39
+ .replace(/[A-Za-z]:[\\/][^\s,;)]*/g, "<path>")
40
+ .replace(/\d+(?:\.\d+)?/g, "<n>")
41
+ .replace(/\s+/g, " ")
42
+ .trim()
43
+ .toLowerCase();
44
+ return createHash("sha256").update(norm, "utf8").digest("hex").slice(0, 12);
45
+ }
46
+
47
+ /**
48
+ * 记录一单的质量指标(旁路统计;失败静默——账本不能影响主流程)。
49
+ * @param {object} entry - { purpose, assertions, rework, interventions, frictions, tokens, ts }
50
+ * @param {object} opts - { config, home, now }
51
+ * @returns {{ok: boolean, skipped?: string, path?: string}}
52
+ */
53
+ export function recordQuality(entry = {}, opts = {}) {
54
+ try {
55
+ const cfg = { ...DEFAULT_LEDGER_CONFIG, ...(opts.config || {}) };
56
+ if (!cfg.enabled) return { ok: false, skipped: "disabled" }; // 默认关:不落盘
57
+ const purpose = entry.purpose ?? entry.task ?? "";
58
+ if (!purpose) return { ok: false, skipped: "no-purpose" };
59
+ const rec = {
60
+ sig: taskSignature(purpose, entry.assertions || []),
61
+ ts: typeof entry.ts === "number" ? entry.ts : (opts.now ?? Date.now()),
62
+ rework: Number(entry.rework) || 0,
63
+ interventions: Number(entry.interventions) || 0,
64
+ frictions: Number(entry.frictions) || 0,
65
+ tokens: Number(entry.tokens) || 0
66
+ };
67
+ const file = ledgerPath(opts.home);
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ fs.appendFileSync(file, JSON.stringify(rec) + "\n", "utf8");
70
+ return { ok: true, path: file };
71
+ } catch (error) {
72
+ return { ok: false, skipped: `error:${error instanceof Error ? error.message : String(error)}` };
73
+ }
74
+ }
75
+
76
+ /** 读取账本(坏行跳过;返回按时间升序的记录数组) */
77
+ export function loadLedger(opts = {}) {
78
+ const file = ledgerPath(opts.home);
79
+ if (!fs.existsSync(file)) return [];
80
+ const out = [];
81
+ for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
82
+ const s = line.trim();
83
+ if (!s) continue;
84
+ try {
85
+ const rec = JSON.parse(s);
86
+ if (rec && typeof rec.sig === "string") out.push(rec);
87
+ } catch {
88
+ // 坏行跳过(账本是旁路数据,不能因一行损坏而失效)
89
+ }
90
+ }
91
+ return out.sort((a, b) => (a.ts || 0) - (b.ts || 0));
92
+ }
93
+
94
+ /** 指标方向:后窗 vs 前窗的均值比较(值越小越好) */
95
+ function direction(before, after) {
96
+ if (before === after) return "持平";
97
+ return after < before ? "改善" : "恶化";
98
+ }
99
+
100
+ /**
101
+ * 质量趋势:按签名取最近 window 单 vs 之前 window 单,输出各指标方向 + 明细。
102
+ * @returns {{sig, count, window, recent, previous, metrics, summary}}
103
+ */
104
+ export function qualityTrend(signature, opts = {}) {
105
+ const cfg = { ...DEFAULT_LEDGER_CONFIG, ...(opts.config || {}) };
106
+ const w = Math.max(1, Number(opts.window ?? cfg.window) || 5);
107
+ const rows = loadLedger(opts).filter((r) => r.sig === signature);
108
+ const recent = rows.slice(-w);
109
+ const previous = rows.slice(-2 * w, -w);
110
+ const avg = (list, key) => (list.length === 0 ? 0 : list.reduce((a, r) => a + (Number(r[key]) || 0), 0) / list.length);
111
+ const metrics = {};
112
+ for (const key of ["rework", "interventions", "frictions", "tokens"]) {
113
+ const b = avg(previous, key);
114
+ const a = avg(recent, key);
115
+ metrics[key] = { before: Number(b.toFixed(2)), after: Number(a.toFixed(2)), direction: direction(b, a) };
116
+ }
117
+ const worst = Object.entries(metrics).filter(([, m]) => m.direction === "恶化").map(([k]) => k);
118
+ const summary = rows.length < 2
119
+ ? `样本不足(${rows.length} 单)——至少 2 单才能比较`
120
+ : worst.length === 0
121
+ ? `方向:持平或改善(近 ${recent.length} 单 vs 前 ${previous.length} 单)`
122
+ : `方向:${worst.join("、")} 恶化(近 ${recent.length} 单 vs 前 ${previous.length} 单)`;
123
+ return { sig: signature, count: rows.length, window: w, recent, previous, metrics, summary };
124
+ }