dsh-rule-engine 0.4.3 → 0.5.1

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
@@ -22,6 +22,13 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
22
22
 
23
23
  当前实现以「模式库兜底」为主,LLM 理解器预留扩展点;所有规则均从 AGENTS.md 实时解析。
24
24
 
25
+ ## 任务契约与反过度工程(可选)
26
+
27
+ - 默认**关闭**;可在规则引擎设置页开启「任务边界与反过度工程」总开关。
28
+ - 开启后默认**观察模式**,只审计提醒;切到 `armed` 才真正拦截。
29
+ - 弹窗询问默认**关闭**;`askEnabled` 开启后,对依赖/hash 等动作走官方 approval 询问。
30
+ - 支持 `/guard mode|budget|contract|label` 命令。
31
+
25
32
  ## 命令
26
33
 
27
34
  | 命令 | 作用 |
@@ -35,6 +42,10 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
35
42
  | `/guard lock` | 立即恢复全部守卫(取消解锁/放行) |
36
43
  | `/guard revoke` | 撤销全部授权记录 |
37
44
  | `/guard reload` | 强制重解析 AGENTS.md |
45
+ | `/guard mode <模式>` | 设置任务契约模式(review/answer/change/monitor/watch/off) |
46
+ | `/guard budget ...` | 设置预算(agents=N files=... deps=allow hash=allow) |
47
+ | `/guard contract` | 查看当前任务契约 |
48
+ | `/guard label <id> <label>` | 给审计记录打标(correct/incorrect/inconclusive) |
38
49
 
39
50
  ## 装配方式
40
51
 
@@ -110,7 +121,6 @@ dsh plugin --profile web add dsh-rule-engine
110
121
  - **本机已安装插件的作者们**:
111
122
  - dsh-guardian(lonelymoon87)
112
123
  - dsh-visualize(Nagi-ovo)
113
- - dsh-usage(kestiny18)
114
124
  - dsh-rules-manager(jilian-dsh)
115
125
  - dsh-vision-router、dsh-super-injector 等未列出的作者
116
126
  - **学习参考的社区文档/库作者**:
package/lib/core/audit.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // P2-9:裁剪改为惰性——每 APPEND_TRIM_INTERVAL 次追加才做一次大小检查,
4
4
  // 避免每次写入都 statSync(高频 deny/纠察时降低 IO)。
5
5
  import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs";
6
+ import { randomUUID } from "node:crypto";
6
7
  import { auditFilePath } from "./paths.js";
7
8
 
8
9
  const LOG_MAX_BYTES = 512 * 1024;
@@ -13,7 +14,7 @@ let appendCount = 0;
13
14
 
14
15
  export function audit(entry) {
15
16
  try {
16
- const line = JSON.stringify({ ts: new Date().toISOString(), ...entry }) + "\n";
17
+ const line = JSON.stringify({ ts: new Date().toISOString(), eventId: randomUUID(), ...entry }) + "\n";
17
18
  appendFileSync(auditFilePath(), line, "utf8");
18
19
  appendCount++;
19
20
  if (appendCount >= APPEND_TRIM_INTERVAL) {
@@ -1,13 +1,22 @@
1
1
  // config.js - 插件配置加载。
2
2
  // 配置缺失时使用默认值;损坏时也回退默认并记录错误,避免裸奔。
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, writeFileSync } from "node:fs";
4
4
  import { configFilePath } from "./paths.js";
5
5
 
6
6
  export const DEFAULT_CONFIG = {
7
7
  enabled: true,
8
8
  correctInject: true,
9
9
  selfProtect: true,
10
- injectLimitPerRulePerSession: 3
10
+ injectLimitPerRulePerSession: 3,
11
+ taskContractEnabled: false,
12
+ askEnabled: false,
13
+ taskContractMode: "observe",
14
+ taskContractDefaults: {
15
+ agentBudget: 0,
16
+ hashPolicy: "deny",
17
+ dependencyPolicy: "ask",
18
+ allowedPaths: null
19
+ }
11
20
  };
12
21
 
13
22
  export function loadPluginConfig() {
@@ -28,3 +37,23 @@ export function loadPluginConfig() {
28
37
  };
29
38
  }
30
39
  }
40
+
41
+ /** 合并并保存插件配置(设置页调用;UTF-8 无 BOM) */
42
+ export function savePluginConfig(partial) {
43
+ const current = loadPluginConfig();
44
+ const next = {
45
+ ...current,
46
+ ...(partial || {}),
47
+ taskContractDefaults: {
48
+ ...DEFAULT_CONFIG.taskContractDefaults,
49
+ ...((current.taskContractDefaults || {})),
50
+ ...((partial?.taskContractDefaults) || {})
51
+ }
52
+ };
53
+ // 规范化
54
+ next.taskContractEnabled = next.taskContractEnabled === true;
55
+ next.askEnabled = next.askEnabled === true;
56
+ next.taskContractMode = next.taskContractMode === "armed" ? "armed" : "observe";
57
+ writeFileSync(configFilePath(), JSON.stringify(next, null, 2) + "\n", "utf8");
58
+ return next;
59
+ }
@@ -0,0 +1,234 @@
1
+ // contract.js - 任务契约(Task Contract)
2
+ // 参考 lennney/stop-that-shit 的 review/answer/change/monitor/watch 模式与预算机制。
3
+ // 纯函数,可独立测试;不依赖 Cordis。
4
+ import { normalizePath } from "./authorization.js";
5
+
6
+ export const MODES = new Set(["review", "answer", "change", "monitor", "watch", "off"]);
7
+ export const LEVELS = new Set(["watch", "guard", "lock", "off"]);
8
+ export const HASH_POLICIES = new Set(["deny", "ask", "allow"]);
9
+ export const SCOPE_POLICIES = new Set(["deny", "ask", "allow"]);
10
+
11
+ export function defaultContract() {
12
+ return {
13
+ mode: "unconfirmed",
14
+ level: "watch",
15
+ agentBudget: 0,
16
+ agentsUsed: 0,
17
+ hashPolicy: "deny",
18
+ allowedPaths: null,
19
+ dependencyPolicy: "ask",
20
+ source: "default"
21
+ };
22
+ }
23
+
24
+ /** 从 /guard mode 指令解析契约模式;无法解析返回 null */
25
+ export function parseModeCommand(text) {
26
+ const m = /^mode\s+([a-z]+)(?:\s+(watch|guard|lock|off))?$/i.exec(String(text || "").trim());
27
+ if (!m) return null;
28
+ const mode = m[1].toLowerCase();
29
+ if (!MODES.has(mode)) return null;
30
+ let level = m[2]?.toLowerCase() || null;
31
+ if (mode === "watch" || mode === "off") level = level || mode;
32
+ else if (!level) level = "guard";
33
+ if (!LEVELS.has(level)) return null;
34
+ return { mode, level };
35
+ }
36
+
37
+ /** 从 /guard budget 指令解析预算;无法解析返回 null */
38
+ export function parseBudgetCommand(text) {
39
+ const s = String(text || "").trim();
40
+ if (!/^budget\b/i.test(s)) return null;
41
+ const tokens = s.replace(/^budget\b/i, "").split(/[\s,]+/).filter(Boolean);
42
+ if (tokens.length === 0) return null;
43
+ const out = {};
44
+ let changed = false;
45
+ for (const token of tokens) {
46
+ const agents = /^agents=(\d+)$/i.exec(token);
47
+ if (agents) {
48
+ out.agentBudget = Math.min(Number(agents[1]), 8);
49
+ changed = true;
50
+ continue;
51
+ }
52
+ const files = /^files=(.+)$/i.exec(token);
53
+ if (files) {
54
+ out.allowedPaths = files[1].split("|").map((v) => normalizePath(v)).filter(Boolean);
55
+ changed = true;
56
+ continue;
57
+ }
58
+ const hash = /^hash=(deny|ask|allow)$/i.exec(token);
59
+ if (hash && HASH_POLICIES.has(hash[1].toLowerCase())) {
60
+ out.hashPolicy = hash[1].toLowerCase();
61
+ changed = true;
62
+ continue;
63
+ }
64
+ const deps = /^deps=(deny|ask|allow)$/i.exec(token);
65
+ if (deps && SCOPE_POLICIES.has(deps[1].toLowerCase())) {
66
+ out.dependencyPolicy = deps[1].toLowerCase();
67
+ changed = true;
68
+ continue;
69
+ }
70
+ }
71
+ return changed ? out : null;
72
+ }
73
+
74
+ /** 从用户自然语言推断任务模式;低置信返回 null */
75
+ export function naturalMode(text, previous = defaultContract()) {
76
+ const s = String(text || "").trim();
77
+ if (/^(?:stop|stop now|停止|停下来)[.!。!\s]*$/i.test(s)) {
78
+ return { mode: "answer", source: "explicit-stop" };
79
+ }
80
+ if (/\breview only\b|\b(?:do not|don't) (?:edit|change|fix) (?:anything|the (?:repo|repository|files?|code))\b|只审查|只看不改|不要修改(?:任何|代码|文件)/i.test(s)) {
81
+ return { mode: "review", source: "natural-explicit" };
82
+ }
83
+ if (/\banswer only\b|只回答/i.test(s)) {
84
+ return { mode: "answer", source: "natural-explicit" };
85
+ }
86
+ if (/\bmonitor only\b|只监控|只观察/i.test(s)) {
87
+ return { mode: "monitor", source: "natural-explicit" };
88
+ }
89
+ const wasNonMutating = ["answer", "review", "monitor"].includes(previous.mode);
90
+ const explicitChange = /^(?:please\s+)?(?:fix|implement|change|apply|patch)\b|^(?:请)?(?:修复|修改|实现|应用补丁)|^把.+(?:修复|修改|改掉)/i.test(s);
91
+ if (wasNonMutating && explicitChange) {
92
+ return { mode: "change", source: "natural-explicit" };
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** 应用一次契约变更(指令/自然语言),返回新契约与是否变化 */
98
+ export function applyContract(previous, patch) {
99
+ const next = { ...defaultContract(), ...(previous || {}) };
100
+ if (!patch) return { contract: next, changed: false };
101
+ let changed = false;
102
+ if (patch.mode && patch.mode !== next.mode) {
103
+ next.mode = patch.mode;
104
+ next.agentsUsed = 0;
105
+ changed = true;
106
+ }
107
+ if (patch.level && patch.level !== next.level) {
108
+ next.level = patch.level;
109
+ changed = true;
110
+ }
111
+ if (Number.isInteger(patch.agentBudget) && patch.agentBudget !== next.agentBudget) {
112
+ next.agentBudget = patch.agentBudget;
113
+ next.agentsUsed = 0;
114
+ changed = true;
115
+ }
116
+ if (patch.hashPolicy && patch.hashPolicy !== next.hashPolicy) {
117
+ next.hashPolicy = patch.hashPolicy;
118
+ changed = true;
119
+ }
120
+ if (Array.isArray(patch.allowedPaths)) {
121
+ next.allowedPaths = patch.allowedPaths;
122
+ changed = true;
123
+ }
124
+ if (patch.dependencyPolicy && patch.dependencyPolicy !== next.dependencyPolicy) {
125
+ next.dependencyPolicy = patch.dependencyPolicy;
126
+ changed = true;
127
+ }
128
+ if (patch.source) next.source = patch.source;
129
+ if (patch.mode && !patch.level && next.level === "watch") {
130
+ next.level = "guard";
131
+ changed = true;
132
+ }
133
+ if (next.mode === "unconfirmed" && next.level !== "off") {
134
+ next.level = "watch";
135
+ }
136
+ return { contract: next, changed };
137
+ }
138
+
139
+ /** 全局是否启用任务契约 */
140
+ export function taskContractActive(config) {
141
+ return Boolean(config?.taskContractEnabled);
142
+ }
143
+
144
+ /** 是否处于观察模式(全局 observe 或契约 watch) */
145
+ export function isObserving(contract, config) {
146
+ if (!taskContractActive(config)) return false;
147
+ if (config?.taskContractMode === "armed") {
148
+ return contract?.level === "watch" || contract?.level === "off";
149
+ }
150
+ return true;
151
+ }
152
+
153
+ /** 是否处于 armed(全局 armed 且契约 guard/lock) */
154
+ export function isArmed(contract, config) {
155
+ if (!taskContractActive(config)) return false;
156
+ if (config?.taskContractMode !== "armed") return false;
157
+ return contract?.level === "guard" || contract?.level === "lock";
158
+ }
159
+
160
+ /** 判断 action 是否在允许路径内 */
161
+ export function pathAllowed(path, allowedPaths) {
162
+ if (!Array.isArray(allowedPaths) || allowedPaths.length === 0) return true;
163
+ if (!path) return false;
164
+ const p = normalizePath(path);
165
+ return allowedPaths.some((allowed) => {
166
+ const a = normalizePath(allowed);
167
+ if (a === "**") return true;
168
+ if (a.endsWith("/**")) {
169
+ const prefix = a.slice(0, -3);
170
+ return p === prefix || p.startsWith(prefix.endsWith("/") ? prefix : prefix + "/");
171
+ }
172
+ return p === a;
173
+ });
174
+ }
175
+
176
+ /**
177
+ * 任务契约裁决(纯函数)。
178
+ * 返回 { outcome: 'allow'|'deny'|'ask'|'report', family, reasonCode, reason, nextStep }
179
+ */
180
+ export function decideContractAction({ contract, action, config = {} }) {
181
+ const mode = contract?.mode || "unconfirmed";
182
+ const level = contract?.level || "watch";
183
+ if (mode === "unconfirmed" || level === "watch" || level === "off") {
184
+ return { outcome: "allow", family: null, reasonCode: "CONTROL_INACTIVE", reason: "任务契约未武装,不拦截", nextStep: "" };
185
+ }
186
+
187
+ const nonMutatingMode = ["answer", "review", "monitor"].includes(mode);
188
+ if (nonMutatingMode && action.mutability === "write") {
189
+ return { outcome: "deny", family: "I", reasonCode: "MODE_FORBIDS_MUTATION", reason: `任务模式 ${mode} 不允许修改文件`, nextStep: "改用只读操作,或获得明确的 change 授权" };
190
+ }
191
+ if (nonMutatingMode && action.mutability === "unknown") {
192
+ return { outcome: "deny", family: "I", reasonCode: "MUTABILITY_UNPROVEN", reason: `任务模式 ${mode} 下该操作无法证明只读`, nextStep: "改用明确只读命令,或获得 change 授权" };
193
+ }
194
+
195
+ if (action.hashIntent && contract.hashPolicy !== "allow") {
196
+ if (contract.hashPolicy === "ask" && config.askEnabled) {
197
+ return { outcome: "ask", family: "H", reasonCode: "HASH_NOT_AUTHORIZED", reason: "检测到哈希/校验和操作,当前 hash=ask", nextStep: "获得 hash=allow 或确认消费者" };
198
+ }
199
+ return { outcome: "deny", family: "H", reasonCode: "HASH_NOT_AUTHORIZED", reason: "检测到哈希/校验和操作,当前 hash=deny", nextStep: "使用 hash=allow 或说明消费者" };
200
+ }
201
+
202
+ if (Array.isArray(contract.allowedPaths) && contract.allowedPaths.length > 0 && action.mutability === "write") {
203
+ const outside = (action.affectedPaths || []).filter((p) => !pathAllowed(p, contract.allowedPaths));
204
+ if (action.affectedPaths?.length === 0) {
205
+ return { outcome: "deny", family: "S", reasonCode: "WRITE_PATH_UNPROVEN", reason: "写操作无法证明在文件边界内", nextStep: "使用带明确路径的写工具,或扩大 files= 范围" };
206
+ }
207
+ if (outside.length) {
208
+ return { outcome: "deny", family: "S", reasonCode: "PATH_OUTSIDE_CONTRACT", reason: `写路径超出文件边界:${outside.join(", ")}`, nextStep: "保持在 files= 范围内,或更新文件边界" };
209
+ }
210
+ }
211
+
212
+ if (action.dependencyIntent && contract.dependencyPolicy !== "allow") {
213
+ if (contract.dependencyPolicy === "ask" && config.askEnabled) {
214
+ return { outcome: "ask", family: "S", reasonCode: "DEPENDENCY_NOT_AUTHORIZED", reason: "检测到添加依赖操作,当前 deps=ask", nextStep: "获得 deps=allow 或确认该依赖" };
215
+ }
216
+ return { outcome: "deny", family: "S", reasonCode: "DEPENDENCY_NOT_AUTHORIZED", reason: "检测到添加依赖操作,当前 deps=deny", nextStep: "使用 deps=allow 或说明必要性" };
217
+ }
218
+
219
+ if (action.mutability === "delegate" && action.unboundedDelegation) {
220
+ return { outcome: "deny", family: "S", reasonCode: "UNBOUNDED_DELEGATION", reason: "该委托可能无界启动子代理,无法满足 agents=N", nextStep: "使用显式子代理调用,或关闭任务契约" };
221
+ }
222
+
223
+ const delegationCount = action.mutability === "delegate" ? (Number.isInteger(action.delegationCount) ? action.delegationCount : 1) : 0;
224
+ if (action.mutability === "delegate" && (contract.agentsUsed + delegationCount > contract.agentBudget)) {
225
+ return { outcome: "deny", family: "S", reasonCode: "AGENT_BUDGET_EXHAUSTED", reason: `子代理预算不足:已用 ${contract.agentsUsed}/${contract.agentBudget},本次需要 ${delegationCount}`, nextStep: "继续本地完成,或获得 agents=N 授权" };
226
+ }
227
+
228
+ return { outcome: "allow", family: null, reasonCode: "WITHIN_CONTRACT", reason: "动作在任务契约内", nextStep: "" };
229
+ }
230
+
231
+ export function contractSummary(contract) {
232
+ const c = contract || defaultContract();
233
+ return `mode=${c.mode}; agents=${c.agentsUsed}/${c.agentBudget}; hash=${c.hashPolicy || "deny"}; deps=${c.dependencyPolicy || "ask"}; files=${Array.isArray(c.allowedPaths) && c.allowedPaths.length ? c.allowedPaths.join("|") : "unbounded"}; level=${c.level}`;
234
+ }
@@ -25,6 +25,8 @@ import { describeAuth, describeOp, findMatchingAuth, operationOf, askQuestionTex
25
25
  import { computeMountSignature, profileNameFromArgs } from "./mount-signature.js";
26
26
  import { findBackupForPath, getSessionState, maybeReloadIfChanged } from "./state.js";
27
27
  import { isVersionedFile, validateEditedFile } from "./version-guard.js";
28
+ import { decideContractAction, defaultContract, isArmed } from "./contract.js";
29
+ import { classifyAction } from "./overengineering.js";
28
30
 
29
31
  function sessionIdOf(exec) {
30
32
  const agent = exec?.agent;
@@ -160,6 +162,22 @@ export function nonBundleInProfileBundles(name, args) {
160
162
  return bad.length ? bad : null;
161
163
  }
162
164
 
165
+ /** 任务契约守卫:仅在总开关开启且会话 armed 时硬拦;ask 场景交给 tools/pre-execute */
166
+ function taskContractGuardDecision(state, exec, session) {
167
+ if (!state.taskContract?.taskContractEnabled) return null;
168
+ const contract = session?.contract || defaultContract();
169
+ if (!isArmed(contract, state.taskContract)) return null;
170
+ const action = classifyAction(exec?.name, exec?.arguments);
171
+ const dec = decideContractAction({ contract, action, config: state.taskContract });
172
+ if (dec.outcome === "deny") {
173
+ return makeHit(
174
+ { ruleId: "__task-contract", title: `任务契约:${dec.reasonCode}`, action: "deny" },
175
+ `【硬拦截】${dec.reason}(${dec.reasonCode}|任务契约|放行:${dec.nextStep || "..."}|ERR-${Math.random().toString(36).slice(2, 8).toUpperCase()})`
176
+ );
177
+ }
178
+ return null;
179
+ }
180
+
163
181
  /**
164
182
  * 裁决一次工具调用。
165
183
  * @param {object} state createState 返回的运行时状态
@@ -181,6 +199,10 @@ export function guardDecision(state, exec, now = Date.now()) {
181
199
  // 只读操作无条件放行(read/grep/glob/read_image/str_replace_editor view)
182
200
  if (isReadOnlyTool(name, args)) return null;
183
201
 
202
+ // 任务契约守卫(总开关关闭时不生效)
203
+ const contractHit = taskContractGuardDecision(state, exec, session);
204
+ if (contractHit) return contractHit;
205
+
184
206
  // E3:已有匹配授权时,拦截重复 ask_user_question,避免 AI 反复询问已授权事项
185
207
  if (name === "ask_user_question") {
186
208
  const qText = askQuestionText(args?.questions);
@@ -0,0 +1,95 @@
1
+ // overengineering.js - 反过度工程模式库(SHIT)
2
+ // 参考 lennney/stop-that-shit:Scope / Hash / Intent / Task thrashing。
3
+ // 纯函数,可独立测试。
4
+ import { commandText, isReadOnlyTool, pathTarget } from "./patterns.js";
5
+ import { normalizePath } from "./authorization.js";
6
+
7
+ const HASH_RE =
8
+ /\b(?:get-filehash|sha256sum|sha1sum|md5sum|openssl\s+dgst|certutil\s+-hashfile|checksum)\b/i;
9
+
10
+ const DEPENDENCY_RE =
11
+ /\b(?:npm|pnpm|yarn|bun|pip|pip3|poetry|go\s+get|gem\s+install|apt-get\s+install|brew\s+install|winget\s+install)\b[\s\S]{0,120}?\b(?:install|add|update|upgrade|i\b)\b/i;
12
+
13
+ const READ_WORDS_RE =
14
+ /\b(?:get-content|get-childitem|get-item|get-command|get-date|select-string|findstr|cat|type|dir|ls|grep|more|netstat|where|test-path|read)\b/i;
15
+
16
+ /** 是否命中“无消费者哈希/校验和”类动作 */
17
+ export function detectHashIntent(toolName, args) {
18
+ const name = String(toolName || "");
19
+ const cmd = commandText(args) || "";
20
+ if (name === "pwsh" || name === "bash") return HASH_RE.test(cmd);
21
+ const text = JSON.stringify(args || {});
22
+ return HASH_RE.test(text);
23
+ }
24
+
25
+ /** 是否命中“添加依赖”类动作 */
26
+ export function detectDependencyIntent(toolName, args) {
27
+ const name = String(toolName || "");
28
+ const cmd = commandText(args) || "";
29
+ if (name === "pwsh" || name === "bash") return DEPENDENCY_RE.test(cmd);
30
+ const text = JSON.stringify(args || {});
31
+ return DEPENDENCY_RE.test(text);
32
+ }
33
+
34
+ /** 粗分类一次工具调用的可变更性 */
35
+ export function classifyAction(toolName, args) {
36
+ const name = String(toolName || "");
37
+ const p = pathTarget(args);
38
+ const cmd = commandText(args);
39
+ let mutability = "unknown";
40
+ if (isReadOnlyTool(name, args)) {
41
+ mutability = "read";
42
+ } else if (name === "edit" || name === "write" || (name === "str_replace_editor" && args?.command !== "view")) {
43
+ mutability = "write";
44
+ } else if (name === "pwsh" || name === "bash") {
45
+ // 无法证明只读的一律视为 unknown(armed 下会按 MUTABILITY_UNPROVEN 处理)
46
+ mutability = "unknown";
47
+ } else if (name === "subagent" || name === "tool-subagent" || name === "tool-subagent-fork" || name === "workflow") {
48
+ mutability = "delegate";
49
+ }
50
+ const affectedPaths = p ? [normalizePath(p)] : [];
51
+ const delegationCount = name === "workflow" ? 0 : 1;
52
+ const unboundedDelegation = name === "workflow";
53
+ return {
54
+ mutability,
55
+ hashIntent: detectHashIntent(name, args),
56
+ dependencyIntent: detectDependencyIntent(name, args),
57
+ affectedPaths,
58
+ delegationCount,
59
+ unboundedDelegation
60
+ };
61
+ }
62
+
63
+ /** 记录一次工具动作(用于 Task thrashing 检测) */
64
+ export function recordAction(session, toolName, args) {
65
+ if (!session || !Array.isArray(session.recentActions)) session.recentActions = [];
66
+ const key = `${toolName}:${commandText(args) || JSON.stringify(args || {})}`;
67
+ session.recentActions.push({ key, at: Date.now() });
68
+ if (session.recentActions.length > 20) session.recentActions = session.recentActions.slice(-20);
69
+ return session.recentActions;
70
+ }
71
+
72
+ /**
73
+ * 判断是否为重复任务打转(同一工具+同一命令在短时间内出现 ≥3 次)。
74
+ * 返回 true 时建议注入提醒/升级拦截。
75
+ */
76
+ export function isRepeatedTaskAction(session, toolName, args, windowMs = 10 * 60 * 1000) {
77
+ if (!session || !Array.isArray(session.recentActions)) return false;
78
+ const key = `${toolName}:${commandText(args) || JSON.stringify(args || {})}`;
79
+ const now = Date.now();
80
+ const count = session.recentActions.filter((a) => a.key === key && now - a.at <= windowMs).length;
81
+ return count >= 3;
82
+ }
83
+
84
+ /** 输出文本中的越界/过度工程表述检测(B/D 级提示用) */
85
+ export function detectOverengineeringText(text) {
86
+ const s = String(text || "");
87
+ const hits = [];
88
+ if (/(?:顺手|顺便|额外|多加|防止以后|以防万一|先加上|先建个)/.test(s) && /(?:重构|依赖|抽象|兼容|迁移|flag|校验|哈希|hash|全量测试|保险)/i.test(s)) {
89
+ hits.push("检测到可能的过度工程表述:请用 Stop Ladder 四问自证(是否被要求/是否必要/可达证据/省略是否会失败)");
90
+ }
91
+ if (/(?:再检查一遍|再跑一次测试|再审计一次|重新验证一遍)/.test(s) && !/(?:新证据|发现|失败|报错|修改后|变更后)/.test(s)) {
92
+ hits.push("检测到可能的重复打转:请确认是否有新证据,避免 Task thrashing");
93
+ }
94
+ return hits;
95
+ }
package/lib/core/state.js CHANGED
@@ -6,6 +6,7 @@ import { agentsFilePath, disabledRulesFilePath } 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";
9
+ import { defaultContract } from "./contract.js";
9
10
 
10
11
  const MAX_SESSIONS = 200;
11
12
  const MAX_RETRY_KEYS = 500;
@@ -34,10 +35,27 @@ export function createState() {
34
35
  lastEvent: null,
35
36
  reloadCount: 0,
36
37
  mountRevision: 0,
37
- mountSignature: ""
38
+ mountSignature: "",
39
+ taskContract: {
40
+ taskContractEnabled: false,
41
+ askEnabled: false,
42
+ taskContractMode: "observe"
43
+ },
44
+ labels: new Map()
38
45
  };
39
46
  }
40
47
 
48
+ /** 把插件配置中的任务契约设置同步到运行时状态 */
49
+ export function applyTaskContractConfig(state, conf) {
50
+ state.taskContract = {
51
+ taskContractEnabled: conf?.taskContractEnabled === true,
52
+ askEnabled: conf?.askEnabled === true,
53
+ taskContractMode: conf?.taskContractMode === "armed" ? "armed" : "observe",
54
+ defaults: conf?.taskContractDefaults || {}
55
+ };
56
+ return state.taskContract;
57
+ }
58
+
41
59
  /** AGENTS.md mtime 变化时自动重解析(规则 21:规则是流动数据) */
42
60
  export function maybeReloadIfChanged(state, now = Date.now()) {
43
61
  if (now - state.lastMtimeCheck < MTIME_CHECK_INTERVAL_MS) return false;
@@ -95,7 +113,9 @@ export function getSessionState(state, sessionId) {
95
113
  mountAuditSignature: "",
96
114
  lastCommandOutput: "",
97
115
  lastSeen: Date.now(),
98
- turn: freshTurn()
116
+ turn: freshTurn(),
117
+ contract: { ...defaultContract(), ...(state.taskContract?.defaults || {}) },
118
+ recentActions: []
99
119
  };
100
120
  state.sessions.set(key, s);
101
121
  }
@@ -7,6 +7,7 @@ import {
7
7
  TIME_WORDS,
8
8
  URL_RE
9
9
  } from "./patterns.js";
10
+ import { detectOverengineeringText } from "./overengineering.js";
10
11
 
11
12
  /** 从 assistant message 内容中提取纯文本 */
12
13
  export function extractAssistantText(message) {
@@ -181,5 +182,16 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
181
182
  }
182
183
  }
183
184
 
185
+ // 反过度工程/越界表述(Stop Ladder 自证)
186
+ const overengineeringHits = detectOverengineeringText(text);
187
+ for (const reason of overengineeringHits) {
188
+ hits.push({
189
+ ruleId: "__task-contract",
190
+ title: "反过度工程",
191
+ kind: "self-certify",
192
+ reason
193
+ });
194
+ }
195
+
184
196
  return hits;
185
197
  }
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  isQuestionMessage
33
33
  } from "./core/authorization.js";
34
34
  import {
35
+ applyTaskContractConfig,
35
36
  getSessionState,
36
37
  maybeReloadIfChanged,
37
38
  recordAuthorization,
@@ -41,6 +42,23 @@ import {
41
42
  } from "./core/state.js";
42
43
  import { state } from "./core/runtime.js";
43
44
  import { detectViolations, extractAssistantText } from "./core/text-detect.js";
45
+ import {
46
+ applyContract,
47
+ contractSummary,
48
+ decideContractAction,
49
+ defaultContract,
50
+ isArmed,
51
+ isObserving,
52
+ naturalMode,
53
+ parseBudgetCommand,
54
+ parseModeCommand
55
+ } from "./core/contract.js";
56
+ import {
57
+ classifyAction,
58
+ detectOverengineeringText,
59
+ isRepeatedTaskAction,
60
+ recordAction
61
+ } from "./core/overengineering.js";
44
62
  import { writeUnderstanding } from "./core/understanding-store.js";
45
63
  import { agentsFilePath, auditFilePath } from "./core/paths.js";
46
64
  import { isVersionedFile, validateEditedFile } from "./core/version-guard.js";
@@ -53,6 +71,7 @@ export const inject = ["tools", "commands", "agents", "workspaceRegistry", "skil
53
71
 
54
72
  const pluginConfig = loadPluginConfig();
55
73
  state.enabled = pluginConfig.enabled;
74
+ applyTaskContractConfig(state, pluginConfig);
56
75
  // reloadRules 内部已统一刷新理解产物(P0-3),此处不再重复写
57
76
  reloadRules(state);
58
77
 
@@ -89,6 +108,14 @@ function parseArgs(raw) {
89
108
  return raw || {};
90
109
  }
91
110
 
111
+ function sessionIdOfExec(exec) {
112
+ const agent = exec?.agent;
113
+ if (!agent) return "global";
114
+ if (typeof agent.session === "object" && agent.session?.id) return agent.session.id;
115
+ if (typeof agent.session === "string") return agent.session;
116
+ return "global";
117
+ }
118
+
92
119
  function extractUserText(message) {
93
120
  if (!message) return "";
94
121
  if (typeof message === "string") return message;
@@ -151,6 +178,23 @@ function handleSessionEvent(ctx, session, event) {
151
178
  s.lastUserText = text;
152
179
  s.turn.userText = text;
153
180
  s.turn.questionOnly = isQuestionMessage(text);
181
+ if (state.taskContract?.taskContractEnabled) {
182
+ const patch = naturalMode(text, s.contract);
183
+ if (patch) {
184
+ const res = applyContract(s.contract, patch);
185
+ if (res.changed) {
186
+ s.contract = res.contract;
187
+ audit({
188
+ kind: "task-contract",
189
+ rule: "__task-contract",
190
+ name: "任务契约更新",
191
+ event: "user/message",
192
+ reason: `${patch.source}: ${contractSummary(s.contract)}`,
193
+ session: sid
194
+ });
195
+ }
196
+ }
197
+ }
154
198
  if (isAuthMessage(text) || isDirectiveMessage(text)) {
155
199
  recordAuthorization(state, sid, {
156
200
  type: inferTypeFromText(text),
@@ -201,6 +245,28 @@ function handleSessionEvent(ctx, session, event) {
201
245
  if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
202
246
  if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
203
247
  s.turn.toolNames.push(toolName);
248
+ if (state.taskContract?.taskContractEnabled) {
249
+ recordAction(s, toolName, args);
250
+ if (isObserving(s.contract, state.taskContract)) {
251
+ const action = classifyAction(toolName, args);
252
+ const problems = [];
253
+ if (action.hashIntent && s.contract.hashPolicy !== "allow") problems.push("hash 未授权");
254
+ if (action.dependencyIntent && s.contract.dependencyPolicy !== "allow") problems.push("依赖未授权");
255
+ if (isRepeatedTaskAction(s, toolName, args)) problems.push("重复动作打转");
256
+ if (problems.length) {
257
+ audit({
258
+ kind: "task-observe",
259
+ rule: "__task-contract",
260
+ name: "任务契约观察",
261
+ event: "tool/call",
262
+ tool: toolName,
263
+ args: summarizeArgs(args),
264
+ reason: `[观察] ${problems.join(";")}`,
265
+ session: sid
266
+ });
267
+ }
268
+ }
269
+ }
204
270
  const active = activateForToolCall(state.configs, toolName, args);
205
271
  if (active.length) state.lastActive = active.map((c) => ({ ruleId: c.ruleId, title: c.title, reason: `工具 ${toolName} 命中` }));
206
272
  return;
@@ -229,6 +295,23 @@ function handleSessionEvent(ctx, session, event) {
229
295
  }
230
296
  }
231
297
 
298
+ // 任务契约:子代理成功执行后扣减预算(仅 armed 生效)
299
+ if (!isError && pendingCall && state.taskContract?.taskContractEnabled && isArmed(s.contract, state.taskContract)) {
300
+ const action = classifyAction(pendingCall.name, pendingCall.args);
301
+ if (action.mutability === "delegate" && action.delegationCount > 0) {
302
+ s.contract.agentsUsed += action.delegationCount;
303
+ audit({
304
+ kind: "task-budget",
305
+ rule: "__task-contract",
306
+ name: "子代理预算扣减",
307
+ event: "tool/result",
308
+ reason: `已用 ${s.contract.agentsUsed}/${s.contract.agentBudget}`,
309
+ session: sid,
310
+ tool: pendingCall.name
311
+ });
312
+ }
313
+ }
314
+
232
315
  // 规则 27:装配变更成功后全局 revision +1;审计命令输出通过/失败后更新本会话审计 revision
233
316
  if (pendingCall) {
234
317
  if (!isError && isAssemblyMutationTool(pendingCall.name, pendingCall.args)) {
@@ -414,6 +497,10 @@ const USAGE = [
414
497
  " /guard lock 立即恢复全部守卫(取消解锁/放行)",
415
498
  " /guard revoke 撤销全部授权记录",
416
499
  " /guard reload 强制重解析 AGENTS.md",
500
+ " /guard mode <模式> 设置任务契约模式(review/answer/change/monitor/watch/off)",
501
+ " /guard budget ... 设置预算(agents=N files=... deps=allow hash=allow)",
502
+ " /guard contract 查看当前任务契约",
503
+ " /guard label <id> <label> 给审计记录打标(correct/incorrect/inconclusive)",
417
504
  "",
418
505
  "说明:",
419
506
  " - 守卫 = 硬拦截:违反规则的工具调用直接拒绝,模型无法自行绕过;",
@@ -436,6 +523,17 @@ function parseCommand(rawInput) {
436
523
  m = text.match(/^log\s*(\d+)?$/i);
437
524
  if (m) return { kind: "log", n: m[1] ? Number(m[1]) : 10 };
438
525
  if (/^reload$/i.test(text)) return { kind: "reload" };
526
+ if (/^mode\b/i.test(text)) {
527
+ const parsed = parseModeCommand(text);
528
+ if (parsed) return { kind: "mode", ...parsed };
529
+ }
530
+ if (/^budget\b/i.test(text)) {
531
+ const parsed = parseBudgetCommand(text);
532
+ if (parsed) return { kind: "budget", patch: parsed };
533
+ }
534
+ if (/^contract$/i.test(text)) return { kind: "contract" };
535
+ m = text.match(/^label\s+(\S+)\s+(correct|incorrect|inconclusive)$/i);
536
+ if (m) return { kind: "label", eventId: m[1], label: m[2].toLowerCase() };
439
537
  return { kind: "invalid" };
440
538
  }
441
539
 
@@ -453,6 +551,7 @@ async function executeGuard(ctx, invocation) {
453
551
  const parts = [
454
552
  "【规则引擎状态】",
455
553
  ` 总开关:${state.enabled ? "开启" : "已关闭"}`,
554
+ ` 任务契约:${state.taskContract?.taskContractEnabled ? `开启(模式 ${state.taskContract.taskContractMode}|ask ${state.taskContract.askEnabled ? "开" : "关"})` : "关闭"}`,
456
555
  ` 规则容器:${state.configOk ? `正常(${state.configs.length} 条规则)` : "⚠ " + state.configError}`,
457
556
  ` 理解置信度:high ${high} / medium ${medium} / low ${low}`,
458
557
  ` 配置加载:${conf.ok ? "正常" : "⚠ " + conf.error}`,
@@ -525,7 +624,7 @@ async function executeGuard(ctx, invocation) {
525
624
  continue;
526
625
  }
527
626
  const t = (e.ts || "").replace("T", " ").slice(0, 19);
528
- parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}`);
627
+ parts.push(` [${t}] ${e.kind || "?"}|规则 ${e.rule || "?"}|${e.name || ""}${e.eventId ? `(${e.eventId})` : ""}`);
529
628
  if (e.reason) parts.push(` 原因:${e.reason}`);
530
629
  if (e.tool) parts.push(` 工具:${e.tool}|参数:${e.args || ""}`);
531
630
  }
@@ -540,6 +639,42 @@ async function executeGuard(ctx, invocation) {
540
639
  (saved.ok ? `\n理解产物已写入:${saved.path}` : `\n理解产物写入失败:${saved.error}`)
541
640
  };
542
641
  }
642
+ case "mode": {
643
+ if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
644
+ const sid = invocation?.session?.id || "global";
645
+ const s = getSessionState(state, sid);
646
+ const res = applyContract(s.contract, { mode: command.mode, level: command.level, source: "guard-command" });
647
+ if (res.changed) {
648
+ s.contract = res.contract;
649
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务契约更新", event: "command", reason: `/guard mode: ${contractSummary(s.contract)}`, session: sid });
650
+ }
651
+ return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
652
+ }
653
+ case "budget": {
654
+ if (!state.taskContract?.taskContractEnabled) return { kind: "error", text: "任务契约未启用:请先在规则引擎设置页开启总开关。" };
655
+ const sid = invocation?.session?.id || "global";
656
+ const s = getSessionState(state, sid);
657
+ const res = applyContract(s.contract, { ...command.patch, source: "guard-command" });
658
+ if (res.changed) {
659
+ s.contract = res.contract;
660
+ audit({ kind: "task-contract", rule: "__task-contract", name: "任务预算更新", event: "command", reason: `/guard budget: ${contractSummary(s.contract)}`, session: sid });
661
+ }
662
+ return { kind: "success", text: `任务契约:${contractSummary(s.contract)}` };
663
+ }
664
+ case "contract": {
665
+ const sid = invocation?.session?.id || "global";
666
+ const s = getSessionState(state, sid);
667
+ const enabled = state.taskContract?.taskContractEnabled ? "开启" : "关闭(总开关未开启,命令不生效)";
668
+ return { kind: "success", text: `任务契约(总开关:${enabled})\n${contractSummary(s.contract)}` };
669
+ }
670
+ case "label": {
671
+ const entries = readAuditLog(500);
672
+ const found = entries.find((e) => e.eventId === command.eventId);
673
+ if (!found) return { kind: "error", text: `未找到审计事件:${command.eventId}` };
674
+ state.labels.set(command.eventId, command.label);
675
+ audit({ kind: "task-label", rule: "__task-contract", name: "审计人工标注", event: "command", reason: `${command.eventId} = ${command.label}`, session: invocation?.session?.id || "global" });
676
+ return { kind: "success", text: `已标注 ${command.eventId} = ${command.label}` };
677
+ }
543
678
  default:
544
679
  return { kind: "error", text: USAGE };
545
680
  }
@@ -614,6 +749,35 @@ export function apply(ctx) {
614
749
  "dsh-rule-engine guard"
615
750
  );
616
751
 
752
+ // 1.1 任务契约 ask 通道:仅在总开关 + askEnabled 时对“应询问”动作返回 ask
753
+ ctx.on("tools/pre-execute", async (exec, next) => {
754
+ try {
755
+ if (!state.taskContract?.taskContractEnabled || !state.taskContract?.askEnabled) return next();
756
+ const sid = sessionIdOfExec(exec);
757
+ const s = getSessionState(state, sid);
758
+ if (!isArmed(s.contract, state.taskContract)) return next();
759
+ const action = classifyAction(exec?.name, exec?.arguments);
760
+ const dec = decideContractAction({ contract: s.contract, action, config: state.taskContract });
761
+ if (dec.outcome === "ask") {
762
+ audit({
763
+ kind: "task-ask",
764
+ rule: "__task-contract",
765
+ name: "任务契约询问",
766
+ event: "tools/pre-execute",
767
+ tool: exec?.name,
768
+ args: summarizeArgs(exec?.arguments),
769
+ reason: dec.reason,
770
+ session: sid
771
+ });
772
+ return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
773
+ }
774
+ return next();
775
+ } catch (error) {
776
+ ctx.logger?.warn?.("[dsh-rule-engine] tools/pre-execute error", error);
777
+ return next();
778
+ }
779
+ });
780
+
617
781
  // 2. session/event 监听:文本纠察 + 时序状态
618
782
  ctx.on("session/event", (session, event) => {
619
783
  try {
package/lib/service.js CHANGED
@@ -5,7 +5,8 @@ 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
7
  import { readAuditLog } from "./core/audit.js";
8
- import { loadPluginConfig } from "./core/config.js";
8
+ import { loadPluginConfig, savePluginConfig } from "./core/config.js";
9
+ import { applyTaskContractConfig } from "./core/state.js";
9
10
  import { agentsFilePath, auditFilePath } from "./core/paths.js";
10
11
 
11
12
  const REMOTE_METHODS = [
@@ -13,7 +14,9 @@ const REMOTE_METHODS = [
13
14
  "getVersion",
14
15
  "checkUpdate",
15
16
  "getAuditLog",
16
- "getUnderstanding"
17
+ "getUnderstanding",
18
+ "getTaskContractConfig",
19
+ "setTaskContractConfig"
17
20
  ];
18
21
 
19
22
  function currentVersion() {
@@ -170,6 +173,43 @@ class RuleEngineService extends TypertRemoteService {
170
173
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
171
174
  }
172
175
  }
176
+
177
+ /** 读取任务契约配置(设置页) */
178
+ async getTaskContractConfig() {
179
+ try {
180
+ const conf = loadPluginConfig();
181
+ return {
182
+ ok: true,
183
+ config: {
184
+ taskContractEnabled: conf.taskContractEnabled === true,
185
+ askEnabled: conf.askEnabled === true,
186
+ taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
187
+ taskContractDefaults: conf.taskContractDefaults || {}
188
+ }
189
+ };
190
+ } catch (error) {
191
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
192
+ }
193
+ }
194
+
195
+ /** 保存任务契约配置(设置页;写入 rule-engine.json 并热同步 state) */
196
+ async setTaskContractConfig(partial) {
197
+ try {
198
+ const conf = savePluginConfig(partial || {});
199
+ applyTaskContractConfig(state, conf);
200
+ return {
201
+ ok: true,
202
+ config: {
203
+ taskContractEnabled: conf.taskContractEnabled === true,
204
+ askEnabled: conf.askEnabled === true,
205
+ taskContractMode: conf.taskContractMode === "armed" ? "armed" : "observe",
206
+ taskContractDefaults: conf.taskContractDefaults || {}
207
+ }
208
+ };
209
+ } catch (error) {
210
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
211
+ }
212
+ }
173
213
  }
174
214
 
175
215
  export { RuleEngineService, RuleEngineService as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-rule-engine",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "DSH 规则执行引擎 v3:容器解析 AGENTS.md + 理解器 + 匹配机 + 执行框架",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,14 +32,22 @@
32
32
  "type": "git",
33
33
  "url": "https://github.com/jilian-dsh/dsh-rule-engine.git"
34
34
  },
35
+ "homepage": "https://github.com/jilian-dsh/dsh-rule-engine",
36
+ "engines": {
37
+ "node": ">=22"
38
+ },
39
+ "dshCompat": {
40
+ "min": "0.1.0-rc.3",
41
+ "max": "0.2.0"
42
+ },
35
43
  "scripts": {
36
- "test": "node test/run-all.js",
44
+ "test": "node test/run-all.mjs",
37
45
  "check": "node --check lib/index.js",
38
46
  "audit:mount": "node scripts/audit-mount-consistency.mjs --profile web"
39
47
  },
40
48
  "peerDependencies": {
41
- "@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.3",
42
- "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3"
49
+ "@deepseek-ai/dsh-home-paths": ">=0.1.0-rc.3 <0.2",
50
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.3 <0.2"
43
51
  },
44
52
  "dsh": {
45
53
  "bundle": {