dsh-rule-engine 0.5.14 → 0.5.16

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,11 +1,11 @@
1
1
  # dsh-rule-engine
2
2
 
3
3
  ![npm](https://img.shields.io/npm/v/dsh-rule-engine)
4
- ![version](https://img.shields.io/badge/version-0.5.14-blue)
4
+ ![version](https://img.shields.io/badge/version-0.5.16-blue)
5
5
 
6
6
  DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
7
7
 
8
- > 当前版本 **0.5.13**(2026-08-31 已发布三通道:npm=0.5.13 / git 4c52982 / Release v0.5.13 带 tgz;上一版 0.5.12 = 2026-08-30,git 269ea91)。**工作区当前迭代 0.5.14**(2026-09-01:分点三柱——条件句零授权/显式命名对象锚定/clauseId 隔离 + skill 词收紧 + 分点回归测试,发布待授权)。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
8
+ > 当前版本 **0.5.15**(npm 已发布;0.5.14 = 2026-09-01 已发布三通道:npm=0.5.14 / git 8105d3e / Release v0.5.14 带 tgz;上一版 0.5.13 = 2026-08-31,git 4c52982)。本插件面向"规则机器化执行":规则写在 AGENTS.md 里,引擎负责让它们真的被遵守;所有规则动态解析,规则增删改后无需重写插件。
9
9
 
10
10
  ## 项目背景
11
11
 
@@ -58,7 +58,7 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
58
58
  - `node scripts/health-audit.mjs` —— 找茬清单:近 24h 失败/降级类统计(intent-llm 失败、judge-unavailable、verify-gap、inject-skip…)+ 关键导出接线交叉(疑似未接线 = 告警)——"失败可见化",不再有静默躺 20 小时的降级;
59
59
  - 执行协议(本仓库自身交付纪律):方案冻结单(范围/影响面/测试计划/失败预测)→ todo 化 → 小步闭环(每改动立即 `node --check`)→ 对账交付(计划×实际逐项 ✅/❌/跳过原因)。**验收五查(0.5.11 用户定稿:验收 = 改动关联产物全做一遍,不是只跑测试)**:① 本次改动的**全部**测试/用例在 run-all 或对应门禁中收录;② README/手册/版本记录与引擎语义成对(新增行为必写文档);③ 调用点 grep:改公共函数签名/导出 → 全部调用点逐一核对;④ 相关测试断言同步(本例:consistency 死映射 17→16);⑤ 误删/错改产物清理(错误版测试/脚本删除后无残留引用)。**⑥ 实弹验证(0.5.11 用户定稿)**:改动在运行态真实生效 = 重启 DSH 加载(profile 为 link: 时重启即生效)→ 运行态验证(行为/守卫/日志实拍)→ 通过后**才**允许进入发布——**发布必须以"重启生效+验证通过"为前提,未生效验证不得发布**(顺序:改 → 测试 → 重启生效 → 实弹验证 → 发布)。任一未做 = 交付不算完成。**⑦ 发布前置(2026-08-28 用户定稿)**:发布前必须先本地跑测试——本项目测试(test-service/npm test)+ 验证脚本 + 运行态实测(截图/输出证据),未全过不得发布;发布后核对三处成对——README 徽章=package.json 版本(release 脚本自动 bump,需验证)、三通道(npm/git/Release)逐个确认成功(含 Release 带正式 tgz asset);发布后遗留(徽章/README 成对缺口)必须当场修。**发布流程三阶段(2026-08-29 用户定稿)**:阶段 A **内容验证(发布前,只读/本地)**——A1 测试全过(run-all+verify-all+loader+运行态实测)、A2 dry-run 推导正确(`release-plugin.mjs <pkg> --dry-run`:oldVer→nextVer+徽章变化)、A3 版本三处一致(徽章=package.json=发布目标号)、A4 Asset/tag 预检(tgz 已 pack;目标 tag 预检不存在——防 ㉙ 同 tag 占位);阶段 A0 **授权**——展示改动清单+版本号 → ask_user_question 明确授权(规则 26⑤:先授权→运行脚本);阶段 B **一键三通道**——`release-plugin.mjs`(publish→push→Release 带 tgz);阶段 C **事后核对**——三通道确认(npm view/git tag/gh api asset)+ 徽章/README 成对复核 + 本机 link 不重装 + 沉淀(版本记录/engram)。**顺序铁律:A→A0→B→C,验证在发布前(A),不是发布后补(C)——发布内容经确认无误才允许三通道。**
60
60
 
61
- > **版本状态说明(2026-09-01 更新)**:0.5.110.5.12、0.5.13 均已独立发布(见上;0.5.13 = 2026-08-31 三通道)。0.5.14 = 当前工作区迭代(发布前统一以对外号为准)。历史说明:0.5.11 发布时含 0.5.10 git 提交欠账 7 文件补齐(baseline/intent/judge/llm-intent/semantic/tool-catalog/whitelist.js——npm 包本已包含,git 通道缺失)。
61
+ > **版本状态说明(2026-09-01 更新)**:0.5.11-0.5.14 均已独立发布(见上;0.5.14 = 2026-09-01 三通道,git 8105d3e)。历史说明:0.5.11 发布时含 0.5.10 git 提交欠账 7 文件补齐(baseline/intent/judge/llm-intent/semantic/tool-catalog/whitelist.js——npm 包本已包含,git 通道缺失)。
62
62
 
63
63
  ## 版本历史(摘要)
64
64
 
@@ -66,7 +66,8 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
66
66
 
67
67
  | 版本 | 日期 | 要点 |
68
68
  |---|---|---|
69
- | **0.5.14** | 2026-09-01 | 工作区迭代(发布待授权):分点三柱(条件句零授权/显式命名对象锚定/clauseId 隔离)+ skill 词收紧(Skill 名词不再触发技能类)+ 分点回归测试 |
69
+ | **0.5.14** | 2026-09-01 | 分点三柱(条件句零授权/显式命名对象锚定/clauseId 隔离)+ skill 词收紧 + 规则 5 引证检测扩展(内部引用无依据→审计注入)+ 规则 31 查证纪律(B+D)+ README 版本四性对齐 |
70
+ | **0.5.15** | 2026-09-02 | 回合末裁决卡片(host 侧 turn/end 裁决摘要 + client 包 dsh-rule-engine-client:可交互 ✅/❌ 卡片;判例登记一次性(per-block);多次裁决一卡逐条分组;卡片/判例落盘 rule-engine-turn-cards.json 重启不丢) |
70
71
  | **0.5.13** | 2026-08-31 | 通用化(声明式绑定/禁用语义/会话寻址/验证通道/发布适用性门禁)+ 阶段二·三(委派豁免/中文顿号路径/ask 节流区分/D1-D3 注入/F1 规则 2/F2 verify-gap) |
71
72
  | **0.5.12** | 2026-08-30 | 意图优先级修正(动作词先于方案词)、LLM 意图兜底同步等待、@文件引用信号、只读判定三档(写特征/白名单/保守拒)、规则 22 粒度并入会话授权、ERR 码打标链路、F2 打标指纹、F5 契约类别白名单 |
72
73
  | **0.5.11** | 2026-08-29 | 判定内核第一轮:新建豁免补"新建"语义、分析通道移除脚本区语义豁免、12A 与 22-7 判据同源、后台判定模型 = 当前会话模型;词表唯一源(lexicon.js)、rule14 空转映射清理、规则 24 机器执行修正(守卫链覆盖测试) |
@@ -156,6 +157,20 @@ dsh plugin --profile web add dsh-rule-engine
156
157
  - **自由区域(Free Zone)**:AGENTS.md 中 `<!-- free-zone:start -->` / `<!-- free-zone:end -->` 标记框住的区段**整区跳过**(不解析、不产生规则、不硬拦、不审计)——适合放“想生效但不想被机器强制”的软约束(如法律守则)。区内的 `### [规则 F<n>]` 条目由配套插件 dsh-rules-manager 在设置页/`/rules` 中可见可管理。新增自由规则请手动在标记内编写(`/rules add` 只会插入到 free-zone 之前),详见 dsh-rules-manager 的 README「📝 新增一条自由规则(零基础三步)」
157
158
  - **禁用规则联动**:dsh-rules-manager 的「禁用规则」存储(`~/.dsh/disabled-rules.json`)会被引擎读取,被禁用的规则标记为 disabled,不参与硬拦/纠察;恢复启用后自动重新生效
158
159
 
160
+ ## 权限、依赖、外部服务与失败边界(DSH STORE 披露)
161
+
162
+ > 面向插件商城(DSH STORE)自动审核与安装者;普通用户可跳过。
163
+ > 声明原则:只陈述实际能力,不因申请自动上架而省略或弱化;本插件因权限信号被商城保守标记,符合"高权限防护工具需 user-reviewed"的商城契约。
164
+
165
+ - **文件访问**:读写 `~/.dsh/rule-engine.log.jsonl`(审计台账)、`rule-engine-tools.json`(工具白名单)、`rule-engine-verify.json`(验证通过记录)、`rule-engine-turn-cards.json`(回合末判例卡片)、`disabled-rules.json`(读)、`rule-engine.json`(配置,读)、`AGENTS.md`(读 + watch 重载)。**写入仅限插件私有状态文件**;对用户业务文件的写动作只在用户规则触发的守卫流程内执行(如版本守卫的备份/回滚)。
166
+ - **网络**:仅 `lib/service.js` 两处**只读 GET**(`api.github.com` release 检查、`raw.githubusercontent.com` upgrade-impact),8 秒超时、无凭据、无请求体,URL 由 package.json 解析(不访问任意地址)。
167
+ - **命令执行**:无 `child_process`/子进程调用;"命令检测"= 对用户命令文本做正则分析(词表),不运行任何被检测的命令。
168
+ - **凭据**:读取环境变量 `DSH_LLM_PROVIDER` / `DSH_LLM_MODEL` / `DSH_WORKSPACE` 作为运行配置(非密钥);不读取 API Key、令牌;审计与注入消息不含凭据。
169
+ - **依赖**:无运行时 `dependencies`;peer 依赖 `@deepseek-ai/dsh-home-paths`、`@deepseek-ai/dsh-typert-protocol`(官方接口);`package.json` 无安装期生命周期脚本(无 preinstall/install/postinstall/prepare)。
170
+ - **外部服务**:同网络项;失败时静默降级(release 检查失败只影响升级提示展示,不阻断守卫)。
171
+ - **失败边界**:LLM 裁决/意图兜底不可用时 fail-closed(不投递、不误放);审计写入失败不阻断拦截(拦截先于落盘);词表/LLM 双通道判定,低置信不参与硬拦;守卫拒绝仅针对变更类动作,只读操作无条件放行。
172
+ - **权限等级**(保守自评):**高**(可写审计/白名单等持久状态、访问网络只读端点、读取环境变量配置)——建议安装前阅读上文「安全设计」并按需二次审查。
173
+
159
174
  ## 当前局限与后续优化路线
160
175
 
161
176
  当前版本已经具备完整四层骨架,但距离“成熟”仍有距离。以下是一些**难度较高、尚未完全实现**的优化方向,欢迎社区共同推进:
package/cordis.patch.yml CHANGED
@@ -5,3 +5,5 @@
5
5
  name: 'dsh-rule-engine'
6
6
  - id: dsh-rule-engine-service
7
7
  name: 'dsh-rule-engine/lib/service.js'
8
+ - id: dsh-rule-engine-client
9
+ name: 'dsh-rule-engine-client'
@@ -11,6 +11,8 @@ export const DEFAULT_CONFIG = {
11
11
  taskContractEnabled: false,
12
12
  askEnabled: false,
13
13
  taskContractMode: "observe",
14
+ // 0.5.15:回合末裁决卡片开关(默认关——面向大众;设置页开关与任务契约同组)
15
+ turnCard: { enabled: false },
14
16
  taskContractDefaults: {
15
17
  agentBudget: 0,
16
18
  hashPolicy: "deny",
package/lib/core/judge.js CHANGED
@@ -43,21 +43,35 @@ export async function judgeViolation(ctx, state, sessionId, violation, text) {
43
43
  const rawCfg = Array.isArray(state.configs)
44
44
  ? state.configs.find((c) => String(c.ruleId) === String(violation.ruleId))
45
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");
46
+ const prompt = violation.mode === "criticism"
47
+ ? [
48
+ "你是 DSH 规则 22② 语义裁决器。判断下面这段【用户消息】是否构成对执行者的批评/指责/质问(规则 22②:用户批评不构成任何授权)。只输出 JSON,不要其他内容。",
49
+ 'JSON 格式:{"violated": true|false, "note": "一句话理由"}',
50
+ "判定要点:",
51
+ "1. 批评/指责/质问 = 用户对执行者行为的否定、责备、反问指责(如“你又错了”“怎么还在做”“这不对吧”)——violated=true;",
52
+ "2. 普通疑问/信息咨询/技术提问(如“这个功能怎么用”“你确认了吗”)——violated=false",
53
+ "3. 用户引述规则文本或转述他人的批评——violated=false;",
54
+ "4. 拿不准给 false(偏不打扰——这是语义提醒,不是硬拦)。",
55
+ "检测提示:" + String(violation.reason || ""),
56
+ "用户消息:",
57
+ text,
58
+ "只输出 JSON。"
59
+ ].join("\n")
60
+ : [
61
+ "你是 DSH 规则裁决器。判断下面这段【执行者回复】是否违反【规则】。只输出 JSON,不要其他内容。",
62
+ 'JSON 格式:{"violated": true|false, "note": "一句话理由"}',
63
+ "判定要点:",
64
+ "1. 回复若是在表达合规声明(如“我不再/将避免/规则 X 已自证/无重复推销”)或引述规则文本——violated=false;",
65
+ "2. 只有确实发生规则禁止的行为(如真的再次推销/真的发布未声明/真的遗漏事项)才 violated=true;",
66
+ "3. 拿不准时给出倾向性判断(false 偏不打扰)。",
67
+ "规则编号:" + violation.ruleId,
68
+ "规则标题:" + violation.title,
69
+ "检测提示:" + String(violation.reason || ""),
70
+ "规则正文:" + (rawCfg?.body || "(未提供)"),
71
+ "执行者回复:",
72
+ text,
73
+ "只输出 JSON。"
74
+ ].join("\n");
61
75
  const messages = [{ role: "user", content: [{ type: "text", text: prompt }] }];
62
76
  let out = "";
63
77
  for await (const chunk of ctx.llm.stream({
package/lib/core/paths.js CHANGED
@@ -43,3 +43,8 @@ export function verifyFilePath() {
43
43
  export function toolsWhitelistFilePath() {
44
44
  return join(dshHome(), "rule-engine-tools.json");
45
45
  }
46
+
47
+ /** 0.5.15:回合末裁决卡片持久化文件(卡片 + 判例登记 → 重启不丢,历史按钮/判例恢复) */
48
+ export function turnCardsFilePath() {
49
+ return join(dshHome(), "rule-engine-turn-cards.json");
50
+ }
package/lib/core/state.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // state.js - 插件运行时状态(内存态)。
2
2
  // 所有会话级状态以 sessionId 为 key;turn 级状态在 turn/start 重置。
3
- import { readFileSync, statSync } from "node:fs";
3
+ import { readFileSync, writeFileSync, statSync } from "node:fs";
4
4
  import { loadRules } from "./parser.js";
5
- import { agentsFilePath, disabledRulesFilePath } from "./paths.js";
5
+ import { agentsFilePath, disabledRulesFilePath, turnCardsFilePath } from "./paths.js";
6
6
  import { understandAll } from "./understander.js";
7
7
  import { writeUnderstanding } from "./understanding-store.js";
8
8
  import { AUTH_TTL_MS } from "./authorization.js";
@@ -52,6 +52,10 @@ export function createState() {
52
52
  labels: new Map(),
53
53
  // 0.5.12(F2):打标指纹台账持久化行(磁盘 ~/.dsh/rule-engine-labels.json;内存态副本)
54
54
  labelRows: loadLabelsFromDisk(join(dshHome(), "rule-engine-labels.json")),
55
+ // 0.5.15:回合末裁决卡片索引——closing assistant messageId → card 摘要
56
+ // (index.js 在 turn/end 时登记;service.getTurnCard 倒查供 client assistant-actions 拉取)
57
+ // 2026-09-02:持久化(从磁盘加载)——判例是教学数据,重启不丢(历史按钮/判例恢复)
58
+ cardByMessage: loadTurnCardsFromDisk(),
55
59
  askRejections: [], // { sessionId, at }(ask 被拒记录,弹窗消减/节流)
56
60
  llmIntentCache: new Map(), // sha256(text) → verdict(LRU 上限 cfg.cacheSize)
57
61
  llmIntentBudget: new Map(), // sessionId → 当日调用数(上限 cfg.dailyLimitPerSession)
@@ -255,6 +259,8 @@ export function freshTurn() {
255
259
  firstToolName: "",
256
260
  skillNames: [],
257
261
  toolNames: [],
262
+ cardHits: [], // 0.5.15:本回合 guard 命中缓冲(回合末裁决卡片数据源)
263
+ lastAssistantMessageId: "", // 0.5.15:本回合最后 assistant 消息 id(卡片索引锚点)
258
264
  commands: [],
259
265
  reasoningText: "",
260
266
  // 规则 22 粒度升级(2026-08-24):本回合已获授权范围(execute 子句 + ask 授权)
@@ -323,3 +329,34 @@ export function reloadRules(state) {
323
329
  export function findConfig(state, ruleId) {
324
330
  return state.configs.find((c) => String(c.ruleId) === String(ruleId));
325
331
  }
332
+
333
+ // ── 0.5.15:回合末裁决卡片持久化(判例=教学数据,重启不丢)─────────────
334
+ /** 卡片索引磁盘上限(防膨胀:只保留最近 N 条) */
335
+ export const TURN_CARDS_MAX = 200;
336
+
337
+ /** 从磁盘加载卡片索引(损坏/缺文件 → 空 Map,容错不崩) */
338
+ export function loadTurnCardsFromDisk() {
339
+ try {
340
+ const raw = readFileSync(turnCardsFilePath(), "utf8");
341
+ const arr = JSON.parse(raw);
342
+ if (!Array.isArray(arr)) return new Map();
343
+ const m = new Map();
344
+ for (const it of arr) {
345
+ if (it && typeof it.messageId === "string" && it.card) m.set(it.messageId, it.card);
346
+ }
347
+ return m;
348
+ } catch {
349
+ return new Map();
350
+ }
351
+ }
352
+
353
+ /** 保存卡片索引到磁盘(数组化 + 上限裁剪;失败静默——卡片是增强层) */
354
+ export function saveTurnCardsToDisk(state, max = TURN_CARDS_MAX) {
355
+ try {
356
+ let entries = [...(state.cardByMessage || new Map()).entries()];
357
+ if (entries.length > max) entries = entries.slice(entries.length - max);
358
+ writeFileSync(turnCardsFilePath(), JSON.stringify(entries.map(([messageId, card]) => ({ messageId, card })), null, 2) + "\n", "utf8");
359
+ } catch {
360
+ // 持久化失败不阻断主流程(卡片是展示层增强)
361
+ }
362
+ }
@@ -19,6 +19,19 @@ import { detectOverengineeringText } from "./overengineering.js";
19
19
  // F2(2026-08-28 阶段三):交付声明强模式(规则 23④ verify-gap 词面)——
20
20
  // 裸"完成"太宽("完成社区检索/尚未完成/正在完成"误触)→ 强完成声明 + 否定/进行态排除。
21
21
  // 模块级导出(纯函数模块,可测试锁定);LLM 裁决层(deliverSuspects)兜底不变。
22
+ // 用户批评形态(规则 22② 机器提示,2026-09-02;2026-09-02 实弹修正分层):
23
+ // CRITICISM_RE(STRONG)= 辱骂词/连续问叹符 → 确定性批评,直接 B 级提醒(零成本零延迟);
24
+ // CRITICISM_WEAK_RE(WEAK) = 反问/责问形态 → 只产嫌疑(awaitingJudge),交 judge 裁决,
25
+ // 确认批评才提醒、拿不准不打扰——词表只产嫌疑、判定权归模型(兑现 v4.107/v4.113 拍板)。
26
+ // 检测对象=用户消息(session.lastUserText),命中=审计+注入"批评≠授权"提醒(B 级,不拦截)
27
+ export const CRITICISM_RE =
28
+ /(?:草泥马|你妈的|你妈|傻[逼B Xx]|煞笔|智障|神经病|你聋了|你瞎了|你疯了|你傻了|蠢货|废物|滚蛋|[??]{3,}|[!!]{4,})/;
29
+
30
+ // WEAK 嫌疑集:反问/责问/否定比较形态("怎么还在做/又错了/这不对吧/你是不是又…")。
31
+ // 允许偏宽(普通技术疑问如"怎么用"也会进入嫌疑)——判定权在 judge:模型确认才是提醒,拿不准=不打扰。
32
+ export const CRITICISM_WEAK_RE =
33
+ /(?:怎么(?:又|还|居然|仍然|老是|总)?[^,。!?]{0,14}(?:了|呢|啊|?|!|!)?|你(?:又|还|居然|咋|怎么)[^,。!?]{0,12}(?:了|呢|啊|吧)?|(?:又|还|总是|老是|仍然|居然)[^,。!?]{0,12}(?:了|呢|啊|吧)?|这(?:不|有点|根本|完全|哪)?(?:对|行|合理|应该|像话)(?:吧|么|吗|啊|呢)?|你(?:是不是|难道|难道说|该不会)[^,。!?]{0,10}(?:了|呢|吧)?|(?:搞什么|干什么|什么情况|怎么回事|搞砸|搞错了|犯什么错))/;
34
+
22
35
  export const DELIVERY_RE = /(?:已完成|全部(?:[^\s,。;!?]{0,12})完成|已[^\s,。;!?]{0,8}完成|修复完成|落盘完成|验证[^\s,。;!?]{0,6}(?:通过|成功)|全部通过|全部[^\s,。;!?]{0,10}通过|已通过|已修复|搞定)(?:\s*了|!|!|,[^。]*)?(?![^。]*(?:尚未|没有|未|没|还没|未完|待做|待完成))/;
23
36
 
24
37
  /** 从 assistant message 内容中提取纯文本 */
@@ -189,9 +202,30 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
189
202
  }
190
203
  }
191
204
 
205
+ // 规则 22② 机器提示(2026-09-02;实弹修正:STRONG 直接提醒 / WEAK 只产嫌疑交 judge 裁决)
206
+ const cfg22 = byId.get("22");
207
+ if (cfg22 && session.lastUserText) {
208
+ const critText = session.lastUserText;
209
+ if (CRITICISM_RE.test(critText) && !isQuoteOrParaphraseContext(critText, CRITICISM_RE)) {
210
+ hits.push({
211
+ ruleId: "22",
212
+ title: cfg22.title,
213
+ kind: "correct",
214
+ reason: "用户消息含确定性批评形态(规则 22②/手册 5b-2):用户批评不构成任何授权——被批评后应停、按归因四问产出三件套、等指令;禁止因批评启动任何变更"
215
+ });
216
+ } else if (CRITICISM_WEAK_RE.test(critText) && !isQuoteOrParaphraseContext(critText, CRITICISM_WEAK_RE)) {
217
+ hits.push({
218
+ ruleId: "22",
219
+ title: cfg22.title,
220
+ kind: "self-certify",
221
+ mode: "criticism",
222
+ reason: "用户消息疑似批评/质问形态(规则 22②/手册 5b-2,待裁决):若确属批评——不构成任何授权,被批评后应停、按归因四问产出三件套、等指令;禁止因批评启动任何变更"
223
+ });
224
+ }
225
+ }
226
+
192
227
  // 规则 31(2026-09-01 用户拍板):同回合同一只读工具 ≥3 次且思维链无验证目标语 → 提示(B 级:留痕+注入)
193
- const rule31Cfg = byId.get("31");
194
- if (rule31Cfg) {
228
+ const rule31Cfg = byId.get("31"); if (rule31Cfg) {
195
229
  const counts = new Map();
196
230
  const pending = session.turn?.pendingToolCalls;
197
231
  if (pending && typeof pending.values === "function") {
@@ -0,0 +1,49 @@
1
+ // turn-card.js - 回合末裁决摘要(0.5.15 回合末裁决卡片的 host 半)。
2
+ // 输入回合状态(turn),输出给 client 卡片的摘要 JSON。
3
+ // 纯函数可独立测试;不 import 官方包。
4
+ // 数据源:turn.cardHits(index.js 在 guardDecision 命中时缓冲)——本文件只做聚合与格式化。
5
+ // 字段约定:
6
+ // card.verdict = "denied" | "clear"(引擎裁决语义,只读)
7
+ // card.label = "correct" | "incorrect" | null(用户判例,rateTurnCardHost 写入;展示层据此显示 ✅/❌)
8
+
9
+ /** 摘要一个工具调用(命令/路径截断,替白空格) */
10
+ function summarizeArg(s) {
11
+ return String(s || "").replace(/\s+/g, " ").slice(0, 80);
12
+ }
13
+
14
+ /** 卡片稳定键(client 判例登记定位用:回合号×块数×用户文本片段) */
15
+ export function cardKey(card) {
16
+ if (!card) return "";
17
+ return `t${card.turn}@${(card.blocks || []).length}@${String(card.userText || "").slice(0, 16)}`;
18
+ }
19
+
20
+ /**
21
+ * 构建回合裁决摘要。
22
+ * @param {object} turn 回合状态(freshTurn 产物;cardHits 由 index.js 写入)
23
+ * @returns {{turn:number, userText:string, verdict:string, blocks:Array, after:string, key:string, label:null}}
24
+ */
25
+ export function buildTurnCard(turn) {
26
+ const hits = Array.isArray(turn?.cardHits) ? turn.cardHits : [];
27
+ const blocks = hits.map((h, i) => ({
28
+ i,
29
+ tool: String(h?.tool || ""),
30
+ args: summarizeArg(h?.args),
31
+ ruleId: String(h?.ruleId || ""),
32
+ title: String(h?.title || ""),
33
+ reason: String(h?.reason || "").slice(0, 160),
34
+ errId: String(h?.errId || "")
35
+ }));
36
+ const rules = [...new Set(blocks.map((b) => b.ruleId).filter(Boolean))];
37
+ const card = {
38
+ turn: Number(turn?.number || 0),
39
+ userText: String(turn?.userText || "").slice(0, 200),
40
+ verdict: blocks.length > 0 ? "denied" : "clear",
41
+ blocks,
42
+ after: blocks.length > 0
43
+ ? `本回合 ${blocks.length} 次调用被拦(规则 ${rules.join("、")})——已改为请示/修正而非继续执行`
44
+ : "本回合无裁决事件",
45
+ label: null
46
+ };
47
+ card.key = cardKey(card);
48
+ return card;
49
+ }
package/lib/index.js CHANGED
@@ -55,12 +55,14 @@ import {
55
55
  recordAuthorization,
56
56
  recordBackup,
57
57
  reloadRules,
58
- resetTurn
58
+ resetTurn,
59
+ saveTurnCardsToDisk
59
60
  } from "./core/state.js";
60
61
  import { toolClass } from "./core/tool-catalog.js";
61
62
  import { parseWhitelist, mergeWhitelist, serializeWhitelist } from "./core/whitelist.js";
62
63
  import { state } from "./core/runtime.js";
63
64
  import { DELIVERY_RE, detectViolations, extractAssistantText } from "./core/text-detect.js";
65
+ import { buildTurnCard } from "./core/turn-card.js";
64
66
  import { shouldDetectTurn, shouldDeliver } from "./core/semantic.js";
65
67
  import { judgeViolation, judgeViolationsBatch } from "./core/judge.js";
66
68
  import {
@@ -473,6 +475,24 @@ export function handleSessionEvent(ctx, session, event) {
473
475
  }
474
476
  s.turn.pendingRule2 = null;
475
477
  }
478
+ // 0.5.15:回合末裁决摘要(卡片后端;生成失败不影响主流程——卡片非硬依赖)
479
+ try {
480
+ s.lastCard = buildTurnCard(s.turn);
481
+ // 0.5.15:登记 closing assistant messageId → 卡片(client assistant-actions 按消息倒查)
482
+ // 2026-09-02:登记即落盘(判例是教学数据,重启不丢)
483
+ const mid = s.turn.lastAssistantMessageId;
484
+ if (mid && s.lastCard) {
485
+ state.cardByMessage.set(mid, { ...s.lastCard, messageId: mid, sessionId: sid });
486
+ if (state.cardByMessage.size > 500) {
487
+ // 防泄漏:仅保留最近 500 条索引(历史太久远的卡片回放无意义)
488
+ const oldest = state.cardByMessage.keys().next().value;
489
+ if (oldest !== undefined) state.cardByMessage.delete(oldest);
490
+ }
491
+ saveTurnCardsToDisk(state);
492
+ }
493
+ } catch {
494
+ // 摘要失败静默(卡片是展示层增强,不阻断回合主流程)
495
+ }
476
496
  return;
477
497
  }
478
498
  if (event.type === "user/message") {
@@ -995,6 +1015,11 @@ export function handleSessionEvent(ctx, session, event) {
995
1015
  }
996
1016
  if (event.type === "assistant/message") {
997
1017
  const text = extractAssistantText(d.message);
1018
+ // 0.5.15:记录本回合最后一条 assistant 消息 id(回合末卡片索引锚点;
1019
+ // 对 turn/end 来说"最后一条"即 closing——无注入轮干扰,注入轮无卡片需求)
1020
+ if (typeof d.message?.id === "string" && d.message.id) {
1021
+ s.turn.lastAssistantMessageId = d.message.id;
1022
+ }
998
1023
  // v0.5.7 第 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
999
1024
  // 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
1000
1025
  if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
@@ -1572,6 +1597,18 @@ export function apply(ctx) {
1572
1597
  errId: hit.errId,
1573
1598
  session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
1574
1599
  });
1600
+ // 0.5.15:本回合 guard 命中缓冲(回合末裁决卡片数据源——全量记,含 __ 内部拦截)
1601
+ (() => {
1602
+ const sCard = getSessionState(state, sessionIdOfExec(exec));
1603
+ (sCard.turn.cardHits ||= []).push({
1604
+ tool: exec?.name || "",
1605
+ args: summarizeArgs(exec?.arguments || {}),
1606
+ ruleId: hit.ruleId,
1607
+ title: hit.title,
1608
+ reason: hit.reason,
1609
+ errId: hit.errId || ""
1610
+ });
1611
+ })();
1575
1612
  state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
1576
1613
  // 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
1577
1614
  const dkey = `${exec?.name}:${JSON.stringify(exec?.arguments || {})}`;
package/lib/service.js CHANGED
@@ -4,10 +4,11 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
6
6
  import { state } from "./core/runtime.js";
7
- import { readAuditLog } from "./core/audit.js";
7
+ import { readAuditLog, audit } from "./core/audit.js";
8
8
  import { loadPluginConfig, savePluginConfig } from "./core/config.js";
9
- import { applyTaskContractConfig } from "./core/state.js";
10
- import { agentsFilePath, auditFilePath } from "./core/paths.js";
9
+ import { applyTaskContractConfig, saveTurnCardsToDisk } from "./core/state.js";
10
+ import { agentsFilePath, auditFilePath, dshHome } from "./core/paths.js";
11
+ import { labelFingerprint, labelEntry, labelsFilePath, saveLabelsToDisk, upsertLabel } from "./core/label-fingerprint.js";
11
12
 
12
13
  const REMOTE_METHODS = [
13
14
  "getStatus",
@@ -17,7 +18,9 @@ const REMOTE_METHODS = [
17
18
  "getUnderstanding",
18
19
  "getTaskContractConfig",
19
20
  "setTaskContractConfig",
20
- "getRuleStats"
21
+ "getRuleStats",
22
+ "getTurnCard",
23
+ "rateTurnCard"
21
24
  ];
22
25
 
23
26
  function currentVersion() {
@@ -200,7 +203,8 @@ class RuleEngineService extends TypertRemoteService {
200
203
  taskContractEnabled: conf.taskContractEnabled === true,
201
204
  askEnabled: conf.askEnabled === true,
202
205
  taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
203
- taskContractDefaults: conf.taskContractDefaults || {}
206
+ taskContractDefaults: conf.taskContractDefaults || {},
207
+ turnCard: { enabled: conf.turnCard?.enabled === true }
204
208
  }
205
209
  };
206
210
  } catch (error) {
@@ -219,7 +223,8 @@ class RuleEngineService extends TypertRemoteService {
219
223
  taskContractEnabled: conf.taskContractEnabled === true,
220
224
  askEnabled: conf.askEnabled === true,
221
225
  taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
222
- taskContractDefaults: conf.taskContractDefaults || {}
226
+ taskContractDefaults: conf.taskContractDefaults || {},
227
+ turnCard: { enabled: conf.turnCard?.enabled === true }
223
228
  }
224
229
  };
225
230
  } catch (error) {
@@ -245,6 +250,38 @@ class RuleEngineService extends TypertRemoteService {
245
250
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
246
251
  }
247
252
  }
253
+
254
+ /**
255
+ * 0.5.15:按 assistant messageId 取回合末裁决卡片。
256
+ * client 端 assistant-actions 挂载时传当前消息 id;host 用
257
+ * state.cardByMessage 倒查(index.js 在 turn/end 时登记 closing 消息 → 卡片)。
258
+ * 开关 turnCard.enabled=false 时恒返回 null(默认关——面向大众)。
259
+ */
260
+ async getTurnCard(messageId) {
261
+ try {
262
+ const conf = loadPluginConfig();
263
+ if (conf.turnCard?.enabled !== true) return { ok: true, card: null };
264
+ const card = state.cardByMessage?.get?.(String(messageId || ""));
265
+ if (!card) return { ok: true, card: null };
266
+ return { ok: true, card };
267
+ } catch (error) {
268
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
269
+ }
270
+ }
271
+
272
+ /**
273
+ * 0.5.15:判例登记(✅=correct 留档 / ❌=incorrect 学习+指纹放行)。
274
+ * 与 /guard label incorrect 同链路:写 state.labels + 命令指纹台账(持久化)。
275
+ * expectedVerdict:卡片上已显示的打标状态(防重复点击/防旧卡回放误登记)。
276
+ * blockIndex:要登记的是卡片中第几条裁决(一回合多次裁决 → 每条独立判定)。
277
+ */
278
+ async rateTurnCard(messageId, verdict, expectedVerdict, blockIndex) {
279
+ try {
280
+ return rateTurnCardHost(String(messageId || ""), verdict, expectedVerdict, blockIndex);
281
+ } catch (error) {
282
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
283
+ }
284
+ }
248
285
  }
249
286
 
250
287
  /** 纯函数:聚合各会话 ruleStats(C2,可独立测试) */
@@ -261,4 +298,85 @@ export function aggregateRuleStats(sessions) {
261
298
  return Object.values(merged).sort((a, b) => b.detected - a.detected);
262
299
  }
263
300
 
301
+ /**
302
+ * 0.5.15:回合末卡片判例登记(纯函数,可独立测试)。
303
+ * verdict: "correct"(拦对了,留档)/ "incorrect"(拦错了,学习+命令指纹放行)。
304
+ * blockIndex:要登记的是卡片中第几条裁决(一回合多次裁决 → 每条独立判定)。
305
+ * 与 /guard label incorrect 同链路:incorrect 时写命令指纹台账(持久化);
306
+ * correct 只记审计留档(不产生指纹放行)。
307
+ * 一次性语义(2026-09-02 用户拍板):每条裁决独立一次性锁定(per-block,
308
+ * 已登记且非同状态重复 → 拒绝改判;改判走 /guard label 命令行)。
309
+ */
310
+ export function rateTurnCardHost(messageId, verdict, expectedVerdict, blockIndex) {
311
+ const idx = Number.isInteger(blockIndex) ? blockIndex : -1;
312
+ // blockIndex 为 -1 = 兼容旧调用(整卡打标),新 client 必传具体索引
313
+ const card = state.cardByMessage?.get?.(String(messageId || ""));
314
+ if (!card) return { ok: false, error: "未找到该消息对应的裁决卡片" };
315
+ if (!card.key) return { ok: false, error: "卡片不可打标(缺少稳定键)" };
316
+ const isLegacy = idx < 0;
317
+ const blocks = Array.isArray(card.blocks) ? card.blocks : [];
318
+ const block = isLegacy ? null : blocks[idx];
319
+ if (!isLegacy && !block) return { ok: false, error: `裁决块 #${idx} 不存在` };
320
+ if (typeof verdict !== "string" || (verdict !== "correct" && verdict !== "incorrect")) {
321
+ return { ok: false, error: "verdict 只能是 correct 或 incorrect" };
322
+ }
323
+ const label = verdict === "correct" ? "correct" : "incorrect";
324
+ // 当前块的已登记状态(legacy = 卡片级 card.label;block 级 = block.label)
325
+ const currentLabel = isLegacy ? (card.label || null) : (block?.label ?? null);
326
+ // 幂等确认(防重复点击):提交与当前显示相同 → 直接成功,不重复审计/写台账
327
+ if (expectedVerdict && expectedVerdict !== "none" && expectedVerdict === verdict) {
328
+ return { ok: true, verdict: label, messageId, already: true };
329
+ }
330
+ // 一次性语义:已登记且非同状态重复 → 拒绝改判;改判应走 /guard label 命令行
331
+ if (currentLabel === "correct" || currentLabel === "incorrect") {
332
+ return { ok: false, error: `该${isLegacy ? "卡片" : "裁决块"}已登记为 ${currentLabel}(判例一次性,不再更改;如需改判请 /guard label ${currentLabel} 相关事件)` };
333
+ }
334
+ let fingerprintNote = "";
335
+ if (label === "incorrect") {
336
+ // 只对目标 block 的命令做指纹(一回合多次裁决:只学习被判错的那条)
337
+ const target = isLegacy ? blocks : [block];
338
+ for (const b of target || []) {
339
+ if ((b.tool === "pwsh" || b.tool === "bash") && b.args) {
340
+ try {
341
+ const cmd = (b.args.match(/\"command\":\s*\"([^\"]*)\"/) || [])[1] || b.args;
342
+ const fp = labelFingerprint(cmd);
343
+ if (fp) {
344
+ state.labelRows = upsertLabel(state.labelRows || [], labelEntry(fp, "incorrect", card.sessionId || ""));
345
+ fingerprintNote = `;命令指纹已记录:${fp.slice(0, 80)}`;
346
+ }
347
+ } catch {
348
+ // 指纹解析失败不阻断打标
349
+ }
350
+ }
351
+ }
352
+ }
353
+ if (fingerprintNote) {
354
+ saveLabelsToDisk(labelsFilePath(dshHome()), state.labelRows || []);
355
+ }
356
+ // 审计留档(correct/incorrect 均记录,供 /guard log 对质)
357
+ audit({
358
+ kind: "turn-card-verdict",
359
+ rule: "__turn-card",
360
+ name: "回合末卡片判例登记",
361
+ event: "remote",
362
+ reason: `${card.key}${isLegacy ? "" : `#${idx}`} = ${label}(messageId=${messageId}${fingerprintNote})`,
363
+ session: card.sessionId || ""
364
+ });
365
+ if (isLegacy) {
366
+ card.label = label;
367
+ } else {
368
+ block.label = label;
369
+ }
370
+ card.verdictAt = Date.now();
371
+ // 2026-09-02:判例登记即落盘(判例=教学数据,重启不丢)
372
+ saveTurnCardsToDisk(state);
373
+ return {
374
+ ok: true,
375
+ verdict: label,
376
+ messageId,
377
+ blockIndex: isLegacy ? -1 : idx,
378
+ fingerprintNote
379
+ };
380
+ }
381
+
264
382
  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.14",
3
+ "version": "0.5.16",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  // 步骤:版本 bump -> 测试 -> npm pack -> npm publish -> git commit+push -> gh release(带 tgz asset)
12
12
  // 安全:token 经环境变量注入,不在命令文本/日志中打印
13
13
  import { execSync } from "node:child_process";
14
- import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
14
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, statSync } from "node:fs";
15
15
  import { dirname, join, relative, resolve } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
 
@@ -109,8 +109,10 @@ checkDependencyPair();
109
109
 
110
110
  // ── 1. 前置检查 ──────────────────────────────────────────────────
111
111
  if (!/^\d+\.\d+\.\d+$/.test(nextVer)) fail(`版本号格式错误:${nextVer}`);
112
- if (!quiet("gh auth status").includes("Logged in")) fail("gh 未认证");
113
- const whoami = quiet("npm whoami");
112
+ // 2026-09-02 修复:gh auth status"Logged in" 输出在 stderr(新版 gh),stdout 可能为空空
113
+ // 2>&1 合并 stderr,避免已认证被误判"gh 未认证"(实测 jilian-dsh keyring 已登录却判失败)
114
+ if (!quiet("gh auth status 2>&1").includes("Logged in")) fail("gh 未认证");
115
+ const whoami = quiet("npm whoami 2>&1");
114
116
  if (!whoami) fail("npm 未认证(npm whoami 失败)");
115
117
 
116
118
  // ── 1.5 DSH 兼容预检(发布前防“升级后插件不兼容”)──────────────
@@ -217,7 +219,27 @@ const untrackedList = quiet(`cd /d "${repoRoot}" && git status --porcelain -- ${
217
219
  if (untrackedList.length > 0) {
218
220
  console.log(`(未跟踪文件 ${untrackedList.length} 个将一并提交:${untrackedList.slice(0, 6).join(", ")}${untrackedList.length > 6 ? " 等" : ""})`);
219
221
  }
220
- run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec} && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
222
+ // 2026-09-02:提交物个人标识/本机路径扫描(git 提交侧门禁;与 publish-aptitude-check.mjs PERSONAL_RE 同源)
223
+ // 教训 8105d3e:logs/.analysis-tmp 手册草稿曾随 add -A 进入公开库——npm 侧有门禁,git 侧此前没有。
224
+ const PERSONAL_RE = /jilian|季涟|D:\\DeepSeek|D:\/DeepSeek|@qq\.com|@163\.com|@outlook\.com|私人注释|个人标识/;
225
+ function scanStagedPersonal(repoRoot, stageSpec) {
226
+ const files = quiet(`cd /d "${repoRoot}" && git diff --cached --name-only -- ${stageSpec || "."}`)
227
+ .split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
228
+ const bad = [];
229
+ for (const f of files) {
230
+ const p = join(repoRoot, f);
231
+ if (!existsSync(p) || statSync(p).size > 2 * 1024 * 1024) continue;
232
+ const text = readFileSync(p, "utf8");
233
+ // 豁免:LICENSE/版权行=MIT 许可要求版权声明(作者署名是公开出版物必要内容,非泄露)
234
+ if (/^LICENSE/i.test(path.basename(f))) continue;
235
+ if (/Copyright\s*\(c\)/i.test(text)) continue;
236
+ if (PERSONAL_RE.test(text)) bad.push(f);
237
+ }
238
+ if (bad.length) fail(`提交物含个人标识/本机路径(PERSONAL_RE 命中 ${bad.length} 个文件):${bad.slice(0, 4).join(", ")};请脱敏后重试`);
239
+ }
240
+ run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec}`);
241
+ scanStagedPersonal(repoRoot, stageSpec);
242
+ run(`cd /d "${repoRoot}" && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
221
243
  run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
222
244
 
223
245
  // ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────