dsh-rule-engine 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,12 +72,19 @@ dsh plugin --profile web add dsh-rule-engine
72
72
  - 只读操作(read/grep/glob/read_image/str_replace_editor view)无条件放行,拦截只针对变更类操作
73
73
  - 插件自身配置/理解产物对模型只读:直接 `edit/write` 会被守卫拒绝,需 `/guard unlock`
74
74
  - AGENTS.md mtime 变化后自动重解析(`fs.watch` + stat 兜底),规则增删改无需重启
75
+ - **修改插件 lib 代码后必须重启 DSH 生效**:bundle 装配下 `dev_reload_package` 热重载不可靠(报成功但行为仍旧代码,踩坑 65);重启后以行为实测(如“请继续”放行)验证
76
+ - LLM 意图兜底:对词表低置信/歧义的用户消息异步调用 `ctx.llm` 判定意图(sha256 缓存 + 会话每日限额),词表判拦且 LLM 高置信判执行时放行;失败自动降级词表(`rule-engine.json` 的 `llmIntent` 配置段可开关/调阈值)
77
+ - 状态信号:用户“我已重启/已输入/完成”等就绪确认与无消息回合不做规则 22 拦截,敏感操作仍由 12A/13A 把关(规则 22⑩)
75
78
  - 低置信规则不参与硬拦,避免误伤;在 `/guard rules` 中标记人工复核
76
79
  - 授权证据按“操作类型 + 目标路径前缀”结构化匹配,区分“询问”与“授权”
77
80
  - 备份证据按“目标路径 → 备份路径”记录,删除/覆盖前必须存在对应路径且备份文件真实存在
78
81
  - 版本/手册类文件写后自检:版本号连续、append 不覆盖上一行,失败自动回滚并审计
79
82
  - 跨工具一致性:同一敏感操作经 `edit` / `write` / `str_replace_editor` / `pwsh` 必须得到相同拦截/放行结论
80
83
  - 命令输出静默错误检测:全 false/0/null 或与上一条完全一致时审计 + 注入提醒,不阻断
84
+ - **注入提醒通道实测限制(2026-08-24,O1 实测——已修复,批次 6):** 根因:`agent.inject` 在 `session/event` 观察回调内同步调用,命中 dsh-session 的 append 重入保护(`session append cannot reenter`,日志 `kind:inject` 可见);2026-08-24 批次 6 已修复:投递延迟到 append 发布边界之后(宏任务),语义不变(inject 官方语义即“为下一 pre-step 排队、不唤醒”);审计从 `注入异常` 变为 `注入已投递` 可对账(详见局限 6)
85
+ - **消息注入判别(机制 A,2026-08-24)**:`user/message` 先判 `source.kind`(`user` 以外的官方/插件注入一律跳过:不覆盖回合状态、不产生授权),并用已知注入模板兜底(Current runtime context 快照 / Background subagent 通知 / vision-router 挂载提醒 / `[规则引擎]` 前缀),全部留 `source-skip` 审计——系统注入与插件挂载通知不再污染授权池
86
+ - **工具分类制(机制 B,2026-08-24)**:工具按 analysis / artifact / mutating / unknown 四类判定;未归类工具(新装插件的工具)首次调用走 ask 确认(防“参数名猜不出就放行”的绕过),已归类只读命令(`npm test` / `git -C` / `node --check` / `gh auth status` 等,按命令链分段判定)无条件放行
87
+ - **授权双轨 + revoke 全清(机制 C,2026-08-24)**:自动来源授权(执行分点/ask/指令)绝不写入全局池(全局仅显式白名单);`/guard revoke` 全清 session + turn.scopes + global + askRejections;授权路径匹配带边界(`d:/a.txt` 不再误匹配 `d:/a.txt.bak`)
81
88
  - 技能目录实时联动:`ctx.skills` 目录变化后自动刷新,已禁用/不存在的技能不触发 12B
82
89
  - LLM 增量理解:对非 high 置信规则调用 `ctx.llm` 补全结构化理解,失败自动回退模式库;AGENTS.md 变化触发重载后会自动补一次增量理解(按规则+版本去重,不重复烧 token)
83
90
  - D 级自证泛化:按规则特征触发自证提示,每规则每会话限 3 次
@@ -112,6 +119,7 @@ dsh plugin --profile web add dsh-rule-engine
112
119
 
113
120
  6. **输出文本实时拦截**
114
121
  受 DSH 官方架构限制,`assistant/message` 无法“拦下不发”,只能事后审计 + 纠正注入;这是平台边界,不是插件能单独突破的。
122
+ 另外(2026-08-24 实测 O1 → 批次 6 已修复):纠正注入通道根因是引擎在 `session/event` 观察回调内同步调用 `agent.inject`,触发 dsh-session 的 append 同步重入保护(`session append cannot reenter while another append is being published`,`kind:inject` 审计全程可见——注入消息从未到达模型/界面);修复为延迟到 append 发布边界后投递(宏任务 `setTimeout 0`),inject 官方语义本就是“为下一 pre-step 排队、不唤醒”,语义不变;审计 reason 从 `注入异常:session append cannot reenter...` 变为 `注入已投递(agent=...)`,可对账(测试 `test/phase1f-inject.test.mjs` 锁定)。
115
123
 
116
124
  ## 致谢
117
125
 
@@ -1,23 +1,41 @@
1
1
  // authorization.js - 授权证据结构化匹配(P0:授权关联具体操作,区分询问与授权)
2
2
  // 纯函数,可独立测试。
3
3
  import { GENERIC_EXEC_TOOLS, pathTarget, commandText, writeTargetPathsFromCommand, extractCopyPaths } from "./patterns.js";
4
+ import { QUESTION_RE } from "./intent.js";
4
5
 
5
6
  export const AUTH_TTL_MS = 10 * 60 * 1000;
6
7
 
7
8
  const AUTH_WORDS_RE = /(?:可以|同意|授权|去做|开始|执行吧|批准|允许|确认|好的|行|去吧|ok|yes)/i;
8
- const QUESTION_WORDS_RE = /(?:影响吗|可以吗|要不要|是否|能不能|行不行|需不需要|吗\s*$|?|\?)/i;
9
- const DIRECTIVE_RE = /(?:请\s*)?(?:删除|移除|修改|编辑|替换|写入|创建|复制|移动|执行|运行|下载|提交|推送|备份|安装|卸载|清理|启动|停止|重建|修复|保存)/i;
9
+ // 疑问词表统一来自 intent.js(规则 22 意图判定与 12D 授权判定的唯一来源,2026-08-24 合并两套)
10
+ const DIRECTIVE_RE = /(?:请\s*)?(?:删除|移除|修改|编辑|替换|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|创建|复制|移动|执行|运行|下载|提交|推送|备份|安装|卸载|清理|启动|停止|重建|修复|改(?:为|成|动|一下)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|实施|推进|继续|处理|解决)/i;
10
11
  const APPROVAL_RE = /(?:允许|同意|可以|是|好|确认|授权|执行|批准|去吧|开始|ok|yes)/i;
11
12
  const REJECTION_RE = /(?:不允许|拒绝|不要|否|取消|不同意|no|No)/i;
12
13
 
14
+ // 2026-08-24(规则 22 粒度配套):执行许可确认词。
15
+ // 只有「确认/同意/批准/授权/可以 + 即可/现在/马上/继续/开始/执行/落盘/去做/实施/进行……」
16
+ // 才构成执行许可;纯"确认方案/理解/方向"不构成。
17
+ export const APPROVAL_EXEC_RE =
18
+ /(?:确认|同意|批准|授权|可以|允许|好|行|ok|yes)[^\n]{0,16}(?:即可|现在|马上|继续|开始|执行|落盘|去做|实施|进行|删除|修改|改|补|修|做|写入|创建|安装|发布|下载|提交|运行)/i;
19
+
20
+ // 纯方案/理解/方向确认(不构成执行授权)
21
+ const PLAN_ONLY_RE = /确认方案|确认理解|确认方向|确认无误|理解了|明白方向|了解|没问题|收到/i;
22
+
23
+ // 选项/自由文本里的明确动作词(用于 ask 结果判定:选择"执行/调整/修复…"即视为批准)
24
+ const EXEC_ACTION_RE = /执行|开始|做|改|补|修|实施|删除|修改|写入|创建|安装|发布|下载|提交|运行|升级|修复|补充|调整|推进|继续|落盘|保存|处理|解决/i;
25
+
13
26
  const TYPE_HINTS = [
14
27
  { re: /删除|移除|remove|delete/i, type: "delete" },
15
- { re: /修改|编辑|替换|写入|写文件|edit|write|replace/i, type: "write" },
28
+ { re: /修改|编辑|替换|写入|写(?:入|文件|作|下|好|完|成)?|改(?:为|成)?|保存|另存为|edit|write|replace/i, type: "write" },
16
29
  { re: /备份|backup/i, type: "backup" },
17
30
  { re: /git\s+(push|commit)|提交|推送/i, type: "git" },
18
- { re: /执行|命令|运行|run|execute|pwsh|bash/i, type: "command" },
31
+ // 命令类只在有明确命令上下文时推断;"执行/开始/做"等宽泛指令保持 any,避免把普通文件变更误限为 command
32
+ { re: /命令|运行|执行\s*(?:命令|脚本|以下|程序)|run|execute|pwsh|bash|node\s+[\w./\\-]+\.(?:m?js|cjs)|npm\s+(?:run|exec|install)/i, type: "command" },
19
33
  { re: /技能|skill/i, type: "skill" },
20
- { re: /下载|网络|curl|fetch/i, type: "network" }
34
+ { re: /下载|网络|curl|fetch/i, type: "network" },
35
+ // 只读/审查类(置末位,2026-08-25 RB-05):不覆盖在前类别的变更语义(“修改并审查”仍按 write/delete),
36
+ // 仅当无其它类型命中时给只读类型——配合 ACTION_RE 只读词(审查/阅读/验证等),使“审查文件”类执行分点
37
+ // 授权类型为 analysis(只读),不再落入 any 宽泛授权。
38
+ { re: /审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|评估|分析|研究|排查|核实|梳理|调查|诊断/i, type: "analysis" }
21
39
  ];
22
40
 
23
41
  const PATH_TOKEN_RE = /["'][A-Za-z]:[\\/](?!\/)[^"']+["']|[A-Za-z]:[\\/](?!\/)[^\s'"`,。;:!?()【】《》、]+|(?:^|[\s"'`])\/(?:[^\s"'`,。;:!?()【】《》、]+)/g;
@@ -27,7 +45,7 @@ export function normalizePath(p) {
27
45
  return p.replace(/\\/g, "/").toLowerCase();
28
46
  }
29
47
 
30
- /** 从文本推断操作类型 */
48
+ /** 从文本推断操作类型(取第一个命中) */
31
49
  export function inferTypeFromText(text) {
32
50
  const s = String(text || "");
33
51
  for (const hint of TYPE_HINTS) {
@@ -36,6 +54,167 @@ export function inferTypeFromText(text) {
36
54
  return "any";
37
55
  }
38
56
 
57
+ /** 从文本推断全部命中的操作类型(用于一条子句含多个操作,如“修改 A 并删除 B”) */
58
+ export function inferTypesFromText(text) {
59
+ const s = String(text || "");
60
+ const types = [];
61
+ for (const hint of TYPE_HINTS) {
62
+ if (hint.re.test(s)) types.push(hint.type);
63
+ }
64
+ return types.length ? [...new Set(types)] : ["any"];
65
+ }
66
+
67
+ /**
68
+ * 从文本提取路径候选(批次 4 统一入口:pairActionScopes 与 inferPathPrefixesFromText 共用)。
69
+ * 处理两类噪声:
70
+ * 1. 前缀冗余:引号路径同时命中两分支 → 截断前缀(如 "d:/deepseek")被更长匹配覆盖 → 丢弃;
71
+ * 2. 截断可疑:裸含空格路径(无引号)只能提取到空格前的半截("D:\DeepSeek harness\..." → "d:/deepseek")
72
+ * ——匹配后紧跟空白且后随 token 是路径字符开头且非盘符/引号 → 判定为截断 → 丢弃(fail-closed:
73
+ * 宁缺授权,拦截后请用户给完整/引号路径,也不把半截前缀当成授权)。
74
+ * @returns {Array<{index:number,path:string}>} 去噪后的候选(保留原文索引供就近配对)
75
+ */
76
+ function extractPathCandidates(text) {
77
+ const s = String(text || "");
78
+ const all = [...s.matchAll(PATH_TOKEN_RE)]
79
+ .map((m) => {
80
+ const rawTok = m[0];
81
+ const after = s.slice(m.index + rawTok.length);
82
+ const afterChar = after.charAt(0);
83
+ const afterWord = after.match(/^[ \t]+(\S+)/)?.[1] || "";
84
+ const truncated = !/["']$/.test(rawTok) // 引号包裹完整路径不可能是截断
85
+ && /\s/.test(afterChar)
86
+ && afterWord.length > 0
87
+ && !/^("|')?[A-Za-z]:[\\/]/i.test(afterWord) // 后随是新盘符路径 → 不是延续
88
+ && /^[A-Za-z0-9_.[\]\\/-]/i.test(afterWord); // 后随是路径字符(非中文标点/动词)
89
+ return { index: m.index, path: normalizePath(rawTok.replace(/^["']|["']$/g, "")), truncated };
90
+ })
91
+ .filter((p) => p.path && !p.truncated);
92
+ // 前缀冗余:完整匹配覆盖截断前缀(后一字符为空白/结束符)
93
+ return all.filter(
94
+ (p) => !all.some((q) => q !== p && q.path.startsWith(p.path) && /[\s"'`,。;:!?()【】《》、]/.test(q.path.slice(p.path.length, p.path.length + 1) || ""))
95
+ );
96
+ }
97
+
98
+ /** 从文本提取全部绝对路径(用于一条子句含多个路径) */
99
+ export function inferPathPrefixesFromText(text) {
100
+ const unique = [...new Set(extractPathCandidates(text).map((p) => p.path))];
101
+ return unique;
102
+ }
103
+
104
+ /**
105
+ * 委托任务书检测(批次 3,2026-08-24;R2④ 实测驱动)。
106
+ * 特征:≥2 步骤枚举 + 显式绝对路径 + 文本较长 + 委托框架词(任务/步骤/目录下/只允许…)。
107
+ * 命中 → strict 模式:无路径绑定的动作子句不产生全局 scope(委托最小 scope 收紧)。
108
+ * 普通用户消息(短/无步骤枚举/无框架词)不命中 → 保持现状(规则 22⑤ 宽泛指令语义)。
109
+ */
110
+ const DELEGATION_MARKER_RE = /(?:任务|只允许|仅限|受限于|范围内|目录下|依次执行|执行以下|步骤)/;
111
+ export function isDelegationText(text) {
112
+ const s = String(text || "");
113
+ const stepCount = (s.match(/(?:^|\n)\s*\d+[.、).]\s*/g) || []).length;
114
+ if (stepCount < 2) return false;
115
+ if (s.length < 120) return false;
116
+ if (!/(["'][A-Za-z]:[\\/]|[A-Za-z]:[\\/](?!\/))/.test(s)) return false;
117
+ return DELEGATION_MARKER_RE.test(s);
118
+ }
119
+
120
+ /**
121
+ * 从本回合执行子句推导授权范围(规则 22 粒度升级,2026-08-24)。
122
+ * R6④ 分点级精确配对(2026-08-24):每个 execute 子句按「动作词 → 就近路径」精确配对,
123
+ * 不再对 type×path 做笛卡尔积——"修改 A 并删除 B"只产生 {write,A} 与 {delete,B},
124
+ * 绝不产生 {write,B}/{delete,A} 等用户没说的组合。
125
+ * 批次 3(2026-08-24):委托任务书(isDelegationText)→ strict 模式,无路径子句跳过(最小 scope)。
126
+ * @param {object} intents parseUserIntents 返回值
127
+ * @returns {Array<{type:string,pathPrefix:string,source:string,clauseId?:string}>}
128
+ */
129
+ export function scopesFromIntents(intents) {
130
+ if (!intents || !Array.isArray(intents.clauses)) return [];
131
+ const strict = isDelegationText(intents.raw || "");
132
+ const scopes = [];
133
+ for (const c of intents.clauses) {
134
+ if (c.type !== "execute") continue;
135
+ scopes.push(...pairActionScopes(c.raw || "", c.id, strict));
136
+ }
137
+ return scopes;
138
+ }
139
+
140
+ // 动作词 → 操作类型(精确配对用;"执行/运行"保持 any,避免把宽泛指令误限为 command)
141
+ const VERB_TYPE_RE = [
142
+ [/删除|移除/i, "delete"],
143
+ [/备份/i, "backup"],
144
+ [/提交|推送/i, "git"],
145
+ [/下载/i, "network"],
146
+ [/执行|运行/i, "any"],
147
+ [/写|保存|另存为|创建|复制|移动|修改|编辑|替换|改|补|修|做|安装|卸载|清理|修复|重建|启动|停止|调整|改进|优化|升级|迁移|整理|添加|增加/i, "write"]
148
+ ];
149
+ const VERB_RE =
150
+ /删除|移除|修改|编辑|替换|写入|写|保存|另存为|创建|复制|移动|备份|提交|推送|下载|执行|运行|安装|卸载|清理|修复|重建|启动|停止|调整|改进|优化|升级|迁移|整理|添加|增加|改|补|修|做|实施|推进|继续|处理|解决/g;
151
+
152
+ function verbType(word, fullText) {
153
+ for (const [re, type] of VERB_TYPE_RE) {
154
+ if (re.test(word)) {
155
+ // 宽泛"执行/运行"在出现具体命令上下文时仍收窄为 command
156
+ if (type === "any" && /node\s+[\w./\\-]+\.(?:m?js|cjs)|npm\s+(?:run|exec|install)|run\s+|execute/i.test(fullText)) return "command";
157
+ return type;
158
+ }
159
+ }
160
+ return inferTypeFromText(fullText);
161
+ }
162
+
163
+ /** R6④:子句内动作-路径就近配对(纯函数)。strict(委托任务书)下无路径子句跳过,不产生全局。 */
164
+ export function pairActionScopes(raw, clauseId, strict = false) {
165
+ const text = String(raw || "");
166
+ const verbs = [...text.matchAll(VERB_RE)].map((m) => ({ index: m.index, word: m[0] }));
167
+ // 批次 4:统一路径提取(去前缀冗余 + 截断可疑)——仅保留可靠 token 供就近配对
168
+ const paths = extractPathCandidates(text);
169
+ if (verbs.length === 0) {
170
+ // 无动词的 execute 子句(如【执行】标签正文无动作词)→ 回退整句推断(保持旧语义)
171
+ const types = inferTypesFromText(text);
172
+ const pathList = inferPathPrefixesFromText(text);
173
+ // 批次 3:strict(委托任务书)且无路径 → 跳过该子句(最小 scope:无明确路径不授权)
174
+ if (strict && pathList.length === 0) return out;
175
+ const list = pathList.length > 0 ? pathList : [""];
176
+ const out = [];
177
+ for (const type of types) {
178
+ for (const pathPrefix of list) {
179
+ if (!out.some((s) => s.type === type && s.pathPrefix === pathPrefix)) out.push({ type, pathPrefix, source: "clause", clauseId });
180
+ }
181
+ }
182
+ return out;
183
+ }
184
+ const scopes = [];
185
+ for (const v of verbs) {
186
+ // 绑定规则:该动词之后、中间无其他动词间隔的全部路径("修改 A 和 B" → A、B 都绑);
187
+ // 无后续路径时取动词之前同条件的全部路径("把 X 和 Y 修改");再否则全局。
188
+ const between = (p) => verbs.some((v2) => v2.index !== v.index && v2.index > Math.min(p.index, v.index) && v2.index < Math.max(p.index, v.index));
189
+ const after = paths.filter((p) => p.index > v.index && !between(p));
190
+ let boundPaths = after.map((p) => p.path);
191
+ if (boundPaths.length === 0) {
192
+ const before = paths.filter((p) => p.index < v.index && !between(p));
193
+ boundPaths = before.map((p) => p.path);
194
+ }
195
+ if (boundPaths.length === 0) {
196
+ // 批次 3:strict(委托任务书)且无路径 → 跳过(最小 scope);普通消息 → 全局兜底(规则 22⑤)
197
+ if (strict) continue;
198
+ boundPaths = [""];
199
+ }
200
+ const type = verbType(v.word, text);
201
+ for (const pathPrefix of boundPaths) {
202
+ if (!scopes.some((s) => s.type === type && s.pathPrefix === pathPrefix)) {
203
+ scopes.push({ type, pathPrefix, source: "clause", clauseId });
204
+ }
205
+ }
206
+ }
207
+ return scopes;
208
+ }
209
+
210
+ /** 格式化授权范围(用于规则 22 拒绝原因) */
211
+ export function describeScopes(scopes) {
212
+ if (!Array.isArray(scopes) || scopes.length === 0) return "无";
213
+ return scopes
214
+ .map((s) => `${s.type || "any"}|路径 ${s.pathPrefix || "全局"}`)
215
+ .join(";");
216
+ }
217
+
39
218
  /** 从文本提取路径前缀(取最长的路径 token;支持引号包裹的含空格路径) */
40
219
  export function inferPathPrefixFromText(text) {
41
220
  const s = String(text || "");
@@ -90,7 +269,15 @@ export function operationOf(toolName, args) {
90
269
  return { type, pathPrefix, pathPrefixes };
91
270
  }
92
271
 
93
- /** 判断授权记录是否匹配本次操作(支持源/目标多路径任一匹配) */
272
+ /** 路径是否在授权边界内:相等,或为授权路径的子路径(子路径以 / 为边界,防止文件前缀误伤) */
273
+ function pathUnderAuth(p, authPath) {
274
+ if (p === authPath) return true;
275
+ return p.startsWith(authPath.endsWith("/") ? authPath : authPath + "/");
276
+ }
277
+
278
+ /** 判断授权记录是否匹配本次操作(支持源/目标多路径任一匹配)。
279
+ * 2026-08-24(R6.3):纯 startsWith 改为边界匹配——授权 "d:/a.txt" 不再放行 "d:/a.txt.bak"(同前缀文件误伤),
280
+ * 目录前缀(含无尾斜杠的目录名)仍正确匹配子路径。 */
94
281
  export function authMatches(auth, op) {
95
282
  if (!auth || !op) return false;
96
283
  if (auth.type !== "any" && op.type !== "any" && auth.type !== op.type) return false;
@@ -100,7 +287,7 @@ export function authMatches(auth, op) {
100
287
  : (op.pathPrefix ? [op.pathPrefix] : []);
101
288
  if (candidates.length === 0) return false;
102
289
  const authPath = normalizePath(auth.pathPrefix);
103
- if (!candidates.some((p) => normalizePath(p).startsWith(authPath))) return false;
290
+ if (!candidates.some((p) => pathUnderAuth(normalizePath(p), authPath))) return false;
104
291
  }
105
292
  return true;
106
293
  }
@@ -126,15 +313,15 @@ export function describeOp(op) {
126
313
  return `${op.type || "any"}|路径 ${op.pathPrefix || "未指定"}`;
127
314
  }
128
315
 
129
- /** 用户消息是否为“询问影响”而非授权 */
316
+ /** 用户消息是否为“询问影响”而非授权(词表与 intent.js 统一,2026-08-24) */
130
317
  export function isQuestionMessage(text) {
131
- return QUESTION_WORDS_RE.test(String(text || ""));
318
+ return QUESTION_RE.test(String(text || ""));
132
319
  }
133
320
 
134
- /** 用户消息是否包含明确授权词(且不是询问句) */
321
+ /** 用户消息是否包含明确执行许可(确认/同意/授权 + 执行语;且不是询问句) */
135
322
  export function isAuthMessage(text) {
136
323
  const s = String(text || "");
137
- return AUTH_WORDS_RE.test(s) && !isQuestionMessage(s);
324
+ return APPROVAL_EXEC_RE.test(s) && !isQuestionMessage(s);
138
325
  }
139
326
 
140
327
  /** 用户消息是否为直接命令式指令(非询问,视为授权执行) */
@@ -143,9 +330,11 @@ export function isDirectiveMessage(text) {
143
330
  return DIRECTIVE_RE.test(s) && !isQuestionMessage(s);
144
331
  }
145
332
 
146
- /** 选项/自由文本是否表示批准 */
333
+ /** 选项/自由文本是否表示批准(纯"确认方案/理解/方向"不算批准) */
147
334
  export function isApprovalText(text) {
148
- return APPROVAL_RE.test(String(text || ""));
335
+ const s = String(text || "");
336
+ if (PLAN_ONLY_RE.test(s) && !EXEC_ACTION_RE.test(s) && !APPROVAL_EXEC_RE.test(s)) return false;
337
+ return APPROVAL_RE.test(s) || EXEC_ACTION_RE.test(s);
149
338
  }
150
339
 
151
340
  /** 选项/自由文本是否表示拒绝 */
@@ -179,6 +368,46 @@ export function askQuestionText(questions) {
179
368
  return parts.join(" ");
180
369
  }
181
370
 
371
+ /**
372
+ * 仅取 ask 的问题/标题/说明,不含选项文本。
373
+ * 2026-08-24 修复:选项描述里的“仅会话内说明/不跨会话保留”等字样不得影响授权范围判定。
374
+ */
375
+ export function askQuestionCoreText(questions) {
376
+ if (!Array.isArray(questions)) return "";
377
+ const parts = [];
378
+ for (const q of questions) {
379
+ if (q && typeof q === "object") {
380
+ if (q.question) parts.push(q.question);
381
+ if (q.header) parts.push(q.header);
382
+ if (q.detail) parts.push(q.detail);
383
+ } else if (typeof q === "string") {
384
+ parts.push(q);
385
+ }
386
+ }
387
+ return parts.join(" ");
388
+ }
389
+
390
+ /** 提取 ask 结果中用户实际选择的文本(selected 标签 + 自定义输入),用于授权范围判定 */
391
+ export function askResultSelectedText(result) {
392
+ const answers = extractAnswers(result);
393
+ if (!answers || answers.length === 0) return "";
394
+ const parts = [];
395
+ for (const item of answers) {
396
+ const texts = [...(item.selected || []), item.custom || ""].filter(Boolean);
397
+ for (const t of texts) parts.push(t);
398
+ }
399
+ return parts.join(" ");
400
+ }
401
+
402
+ /**
403
+ * 判定 ask 授权是否应为“会话级/全局长时”授权。
404
+ * 只匹配明确的范围词,不再匹配裸“会话内”(避免“仅会话内说明”把一次小授权放大成 12h 全局)。
405
+ */
406
+ const SESSION_WIDE_RE = /(?:全部|本会话|本次会话|整个会话|当前会话|会话内所有|会话内全部|所有后续|剩余所有|所有操作|整个profile|整个工作区|全部推进项|全部操作)/i;
407
+ export function isSessionWideAskText(text) {
408
+ return SESSION_WIDE_RE.test(String(text || ""));
409
+ }
410
+
182
411
  /** 从 ask_user_question 的结果中提取是否批准 */
183
412
  export function askResultApproved(result) {
184
413
  const answers = extractAnswers(result);
@@ -0,0 +1,43 @@
1
+ // baseline.js - 需求基线覆盖核查(2026-08-24 最小第一步)
2
+ // 用途:交付/方案完成类声明时,核对需求基线(rule-engine-baseline.json)中是否仍有
3
+ // planned 未落地需求;缺失/损坏按 fail-closed 处理(审计提示,绝不静默)。
4
+ import { readFileSync } from "node:fs";
5
+ import { baselineFilePath } from "./paths.js";
6
+
7
+ /** 完成类声明的触发词(独立于规则 23④ 的通用完成正则,避免日常“完成”二字误报) */
8
+ export const BASELINE_COMPLETE_RE =
9
+ /方案完成|重构完成|全部完成|整体完成|任务全部完成|彻底完成|收尾完成|全部落地|机制完成/;
10
+
11
+ /**
12
+ * 纯函数判定:完成声明 ∧ 存在 planned 需求 → gap;否则 null。
13
+ * @param {string} text 声明文本
14
+ * @param {Array<{id: string, title?: string, status: string}>} items 基线需求
15
+ * @returns {{count: number, pending: string[]} | null}
16
+ */
17
+ export function computeBaselineGap(text, items) {
18
+ if (!text || !BASELINE_COMPLETE_RE.test(text)) return null;
19
+ if (!Array.isArray(items) || items.length === 0) return null;
20
+ const pending = items
21
+ .filter((i) => i && i.status === "planned")
22
+ .map((i) => `${i.id}:${i.title || ""}`);
23
+ if (pending.length === 0) return null;
24
+ return { count: pending.length, pending };
25
+ }
26
+
27
+ /**
28
+ * 加载需求基线文件。缺失/损坏 → { ok:false }(调用方据此审计,不做静默判定)。
29
+ */
30
+ export function loadBaselineFile(filePath = baselineFilePath()) {
31
+ try {
32
+ const raw = readFileSync(filePath, "utf8");
33
+ const data = JSON.parse(raw);
34
+ const items = Array.isArray(data && data.requirements) ? data.requirements : [];
35
+ return { ok: true, items, raw: data };
36
+ } catch (error) {
37
+ return {
38
+ ok: false,
39
+ items: [],
40
+ error: error instanceof Error ? error.message : String(error)
41
+ };
42
+ }
43
+ }
@@ -16,6 +16,13 @@ export const DEFAULT_CONFIG = {
16
16
  hashPolicy: "deny",
17
17
  dependencyPolicy: "ask",
18
18
  allowedPaths: null
19
+ },
20
+ llmIntent: {
21
+ enabled: true,
22
+ dailyLimitPerSession: 50,
23
+ thresholdHigh: 0.8,
24
+ thresholdLow: 0.5,
25
+ cacheSize: 500
19
26
  }
20
27
  };
21
28
 
@@ -21,6 +21,60 @@ export function defaultContract() {
21
21
  };
22
22
  }
23
23
 
24
+ /**
25
+ * 会话契约初始化(批次 4,阶段 3 默认武装):
26
+ * 优先级:defaults 显式(用户配置)> global 模板(用户 /guard mode|budget 写入且非默认)> armed 推导 > 系统默认。
27
+ * 默认武装(用户拍板 A):armed 且未见显式 mode/level → {mode:"change", level:"guard"};
28
+ * agentBudget 0(系统默认/未显式设置)→ 2(日常可用,避免武装附带"子代理全拦"副作用)。
29
+ * @param {object} cfg config.js 的 taskContract 配置段({taskContractMode, defaults})
30
+ * @param {object|null} globalContract 全局(模板)会话契约;首次/global 自身传 null
31
+ */
32
+ export function initSessionContract(cfg = {}, globalContract = null) {
33
+ const defaults = (cfg && cfg.defaults) || {};
34
+ const base = { ...defaultContract(), ...defaults };
35
+ const globalUserSet = globalContract && globalContract.source && globalContract.source !== "default";
36
+ if (globalUserSet) {
37
+ if (globalContract.mode && globalContract.mode !== base.mode) base.mode = globalContract.mode;
38
+ if (globalContract.level && globalContract.level !== base.level) base.level = globalContract.level;
39
+ if (typeof globalContract.agentBudget === "number" && globalContract.agentBudget > 0 && base.agentBudget === 0) {
40
+ base.agentBudget = globalContract.agentBudget;
41
+ }
42
+ if (globalContract.hashPolicy && globalContract.hashPolicy !== base.hashPolicy) base.hashPolicy = globalContract.hashPolicy;
43
+ if (globalContract.dependencyPolicy && globalContract.dependencyPolicy !== base.dependencyPolicy) base.dependencyPolicy = globalContract.dependencyPolicy;
44
+ if (Array.isArray(globalContract.allowedPaths)) base.allowedPaths = globalContract.allowedPaths;
45
+ }
46
+ const armed = cfg && cfg.taskContractMode === "armed";
47
+ const explicitMode = "mode" in defaults || (globalUserSet && globalContract.mode && globalContract.mode !== "unconfirmed");
48
+ if (armed && base.agentBudget === 0) base.agentBudget = 2;
49
+ if (armed && !explicitMode) {
50
+ base.mode = "change";
51
+ base.level = "guard";
52
+ }
53
+ base.source = "session-init";
54
+ return base;
55
+ }
56
+
57
+ /** 契约裁决 → pre-execute 决策(批次 4:deny 真实物理拦截,不再仅审计放行)。allow/report → null。 */
58
+ export function contractOutcomeToPreDecision(dec) {
59
+ if (!dec) return null;
60
+ if (dec.outcome === "deny") {
61
+ return { kind: "deny", reason: `${dec.reason}(任务契约|${dec.nextStep || "请调整契约或停止该操作"})` };
62
+ }
63
+ if (dec.outcome === "ask") {
64
+ return { kind: "ask", reason: `${dec.reason}(任务契约|${dec.nextStep || "请确认或使用放行词"})` };
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /**
70
+ * 委托预算扣减(批次 5.5,2026-08-24;用户指示提前到发布前):启动即计——纯函数,不污染原对象。
71
+ * 语义:调用即占额度(失败不退还,保守安全面);配合 decideContractAction 的预算越限 deny。
72
+ */
73
+ export function consumeDelegateBudget(contract, count = 1) {
74
+ const n = Math.max(0, Number(count) || 0);
75
+ return { ...contract, agentsUsed: (Number(contract?.agentsUsed) || 0) + n };
76
+ }
77
+
24
78
  /** 从 /guard mode 指令解析契约模式;无法解析返回 null */
25
79
  export function parseModeCommand(text) {
26
80
  const m = /^mode\s+([a-z]+)(?:\s+(watch|guard|lock|off))?$/i.exec(String(text || "").trim());