dsh-rule-engine 0.5.13 → 0.5.15
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 +9 -8
- package/cordis.patch.yml +2 -0
- package/lib/core/authorization.js +28 -4
- package/lib/core/config.js +2 -0
- package/lib/core/intent.js +11 -0
- package/lib/core/lexicon.js +1 -1
- package/lib/core/paths.js +5 -0
- package/lib/core/state.js +40 -2
- package/lib/core/text-detect.js +56 -11
- package/lib/core/turn-card.js +49 -0
- package/lib/index.js +40 -1
- package/lib/service.js +124 -6
- package/package.json +1 -1
- package/scripts/release-plugin.mjs +279 -0
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# dsh-rule-engine
|
|
2
2
|
|
|
3
3
|

|
|
4
|
-

|
|
5
5
|
|
|
6
6
|
DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯一真相源,自动解析规则四要素与执行等级,再通过「工具守卫 + 文本检测 + 时序检查 + 审计台账」执行用户规则,而不是内置一套与用户无关的安全清单。
|
|
7
7
|
|
|
8
|
-
> 当前版本 **0.5.
|
|
8
|
+
> 当前版本 **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)。内容:分点三柱(条件句零授权/显式命名对象锚定/clauseId 隔离)+ skill 词收紧 + 规则 5 引证检测扩展(内部引用无依据提醒)+ 规则 31 查证纪律(B+D)+ README 版本四性对齐。本插件面向"规则机器化执行":规则写在 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-
|
|
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,10 +66,11 @@ DSH 规则执行引擎 v3 的插件实现。它把 `~/.dsh/AGENTS.md` 当作唯
|
|
|
66
66
|
|
|
67
67
|
| 版本 | 日期 | 要点 |
|
|
68
68
|
|---|---|---|
|
|
69
|
-
| **0.5.14** | 2026-
|
|
70
|
-
| **0.5.
|
|
71
|
-
| **0.5.
|
|
72
|
-
| **0.5.
|
|
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 重启不丢) |
|
|
71
|
+
| **0.5.13** | 2026-08-31 | 通用化(声明式绑定/禁用语义/会话寻址/验证通道/发布适用性门禁)+ 阶段二·三(委派豁免/中文顿号路径/ask 节流区分/D1-D3 注入/F1 规则 2/F2 verify-gap) |
|
|
72
|
+
| **0.5.12** | 2026-08-30 | 意图优先级修正(动作词先于方案词)、LLM 意图兜底同步等待、@文件引用信号、只读判定三档(写特征/白名单/保守拒)、规则 22 粒度并入会话授权、ERR 码打标链路、F2 打标指纹、F5 契约类别白名单 |
|
|
73
|
+
| **0.5.11** | 2026-08-29 | 判定内核第一轮:新建豁免补"新建"语义、分析通道移除脚本区语义豁免、12A 与 22-7 判据同源、后台判定模型 = 当前会话模型;词表唯一源(lexicon.js)、rule14 空转映射清理、规则 24 机器执行修正(守卫链覆盖测试) |
|
|
73
74
|
| **0.5.10** | 2026-08-27 | 分析通道(单真源 isAnalysisOp:严格只读 ∪ 分析临时区写)、写类判定单真源(isMutationCommand)、只读词表补全、统一入口加固(转义检测 + 写后校验)、误判打标闭环、已知坑错误码召回 |
|
|
74
75
|
| **0.5.9** | 2026-08-27 | 工具分类单真源(tool-catalog.js)、官方 59 工具全集覆盖、前缀规则(mcp__/esr_/dev_ 等命名空间自动归类)、unknownPolicy(默认 deny)、白名单 v2 带元数据、/guard tools、工具箱覆盖门禁 |
|
|
75
76
|
| **0.5.8** | 2026-08-26 | 白名单持久化(rule-engine-tools.json)、只读命令词表补全(Select-Object 等)、`npm run verify` 注册 |
|
|
@@ -208,7 +209,7 @@ bash scripts/build.sh
|
|
|
208
209
|
|
|
209
210
|
交付前体检(0.5.7 起):`node scripts/verify-all.mjs`(七层:语法/单元/组合冒烟/工具箱覆盖/守卫链覆盖/关联一致性/真实判例)与 `node scripts/health-audit.mjs`(找茬)——详见「质量与验证」。
|
|
210
211
|
|
|
211
|
-
发布:`node
|
|
212
|
+
发布:`node scripts/release-plugin.mjs <插件名> <版本号>`(一键 npm + git + GitHub Release;发布脚本随插件仓库管理——`scripts/release-plugin.mjs`;改发布脚本后须 `--dry-run` + 代码审查,注意 dry-run 不覆盖 git 段)。
|
|
212
213
|
|
|
213
214
|
## License
|
|
214
215
|
|
package/cordis.patch.yml
CHANGED
|
@@ -41,7 +41,10 @@ const TYPE_HINTS = [
|
|
|
41
41
|
// 0.5.12(F1 重证):command 规则补 ①引号包裹路径 ②无扩展名可执行文件(tsc/tsdown 等)
|
|
42
42
|
// ③pnpm/yarn/bun 显式收录(此前靠 npm\s+ 子串巧合命中 pnpm,非设计保证)
|
|
43
43
|
{ re: /命令|运行|执行\s*(?:命令|脚本|以下|程序)|run|execute|pwsh|bash|node\s+(?!-e\b|-p\b|--eval\b|--print\b)(?:['"])?[\w./\\-]+(?:['"])?(?:\s|$)|(?:npm|pnpm|yarn|bun)\s+(?:run|exec|install|add)|(?:^|[^a-z])(?:tsc|tsdown|vite|webpack|rollup)\b/i, type: "command" },
|
|
44
|
-
|
|
44
|
+
// 2026-08-31(skill 词残留收紧):12B 已禁用(技能调用流程),原 /技能|skill/i 把任意文本中的
|
|
45
|
+
// "Skill"名词(如 repo 描述)判成技能类→授权范围不匹配误拦(VCI2RX 实证)。
|
|
46
|
+
// 收紧为**真实技能操作**(skill 工具/技能目录管理上下文),名词性"Skill"不再命中。
|
|
47
|
+
{ re: /(?:技能|skill)\s*(?:调用|目录|管理|列表|启用|禁用|安装|删除|加载|查询)|(?:启用|禁用|调用|安装|删除|加载|查询|打开|查看|管理)\s*技能|(?:启用|禁用|调用|安装|删除|加载|查询|打开|查看|管理)\s*skill\b|skill\s+(?:tools?|dir|enable|disable|list|install)\b|\/guard\s+skills?\b/i, type: "skill" },
|
|
45
48
|
// 0.5.12(F1 重证):network 词表由裸子串改词边界+上下文——`fetch` 出现在目录名/标识符中
|
|
46
49
|
//(dsh-web-fetch-playwright)不再误判;真网络形态(URL/调用/下载动词)仍命中
|
|
47
50
|
{ re: /下载|网络|https?:\/\/\S*|(?:^|[^a-z])(?:curl|fetch|wget|iwr|Invoke-WebRequest|Invoke-RestMethod)\s*(?:\(|\.|[^\w]|$)/i, type: "network" },
|
|
@@ -231,7 +234,7 @@ export function pairActionScopes(raw, clauseId, strict = false) {
|
|
|
231
234
|
const type = verbType(v.word, text);
|
|
232
235
|
for (const pathPrefix of boundPaths) {
|
|
233
236
|
if (!scopes.some((s) => s.type === type && s.pathPrefix === pathPrefix)) {
|
|
234
|
-
scopes.push({ type, pathPrefix, source: "clause", clauseId });
|
|
237
|
+
scopes.push({ type, pathPrefix, source: "clause", clauseId, object: extractActionObjects(text) });
|
|
235
238
|
}
|
|
236
239
|
}
|
|
237
240
|
}
|
|
@@ -242,7 +245,7 @@ export function pairActionScopes(raw, clauseId, strict = false) {
|
|
|
242
245
|
export function describeScopes(scopes) {
|
|
243
246
|
if (!Array.isArray(scopes) || scopes.length === 0) return "无";
|
|
244
247
|
return scopes
|
|
245
|
-
.map((s) => `${s.type || "any"}|路径 ${s.pathPrefix || "全局"}`)
|
|
248
|
+
.map((s) => `${s.type || "any"}|路径 ${s.pathPrefix || "全局"}${Array.isArray(s.object) && s.object.length ? `|对象 ${s.object.join(",")}` : ""}`)
|
|
246
249
|
.join(";");
|
|
247
250
|
}
|
|
248
251
|
|
|
@@ -297,7 +300,22 @@ export function operationOf(toolName, args) {
|
|
|
297
300
|
pathPrefix = "";
|
|
298
301
|
}
|
|
299
302
|
|
|
300
|
-
return { type, pathPrefix, pathPrefixes };
|
|
303
|
+
return { type, pathPrefix, pathPrefixes, commandText: cmd || "" };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 柱子 B(2026-08-31):显式命名对象锚定——分点文本中"X 仓库/repo/目录/项目/包/文件"的
|
|
308
|
+
* **实体名 token**(含连字符/点/下划线/数字的命名形态;"skill 仓库"这类类别概念词不锚定——
|
|
309
|
+
* 防过度误拦)。锚定后授权仅覆盖该对象;未锚定(空)→ 不收紧(维持既有语义)。
|
|
310
|
+
*/
|
|
311
|
+
const NAMED_OBJECT_RE = /([A-Za-z0-9]+(?:[-_.][A-Za-z0-9]+)+|[\u4e00-\u9fff]{2,12}[A-Za-z0-9]*)(?:\s*)(?:远程|远端)?(?:仓库|repo(?:sitory)?|目录|项目|包|package|文件)/gi;
|
|
312
|
+
export function extractActionObjects(text) {
|
|
313
|
+
const out = new Set();
|
|
314
|
+
for (const m of String(text || "").matchAll(NAMED_OBJECT_RE)) {
|
|
315
|
+
const token = m[1].trim();
|
|
316
|
+
if (token.length >= 3) out.add(token.toLowerCase());
|
|
317
|
+
}
|
|
318
|
+
return [...out];
|
|
301
319
|
}
|
|
302
320
|
|
|
303
321
|
/** 路径是否在授权边界内:相等,或为授权路径的子路径(子路径以 / 为边界,防止文件前缀误伤) */
|
|
@@ -320,6 +338,12 @@ export function authMatches(auth, op) {
|
|
|
320
338
|
const authPath = normalizePath(auth.pathPrefix);
|
|
321
339
|
if (!candidates.some((p) => pathUnderAuth(normalizePath(p), authPath))) return false;
|
|
322
340
|
}
|
|
341
|
+
// 柱子 B(2026-08-31):对象锚定——授权声明了显式命名对象 → 操作命令文本必须命中该对象
|
|
342
|
+
//("推送 X 仓库"不覆盖"推送 Y 仓库";未锚定对象=不收紧)
|
|
343
|
+
if (Array.isArray(auth.object) && auth.object.length > 0) {
|
|
344
|
+
const opText = String(op.commandText || "").toLowerCase();
|
|
345
|
+
if (!opText || !auth.object.some((o) => opText.includes(String(o).toLowerCase()))) return false;
|
|
346
|
+
}
|
|
323
347
|
return true;
|
|
324
348
|
}
|
|
325
349
|
|
package/lib/core/config.js
CHANGED
|
@@ -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/intent.js
CHANGED
|
@@ -37,6 +37,11 @@ export const TAG_RE = /【(执行|方案|问询|信息)】/;
|
|
|
37
37
|
export const QUESTION_RE = QUESTION_WORDS_RE;
|
|
38
38
|
|
|
39
39
|
const DEFER_RE = /待确认|确认后再|等我确认|先方案|先别|不要执行|之后再说|确认后|再确认|先确认|不要直接/;
|
|
40
|
+
// 2026-08-31(柱子 C:条件句识别——"推送完成后…/等 X 了…/待…后…"型分点):
|
|
41
|
+
// 条件句分点 = 依赖前序分点完成后的后续动作,**本条不产生任何授权/scope**(安全方向:宁拦不放)。
|
|
42
|
+
// 词面拒绝"推送/上传"等动作词被当作本条独立动作(3 号分点"推送完成后发地址"实证:引擎曾把
|
|
43
|
+
// "推送"当本条动作致 git 全局授权弥漫——分点失效根因之一)。
|
|
44
|
+
const CONDITIONAL_RE = /(?:完成|结束|做完|弄完|搞定|好了|恢复|重启?)\s*(?:了|之后|后|以后|再)|(?:等|待|等\s*[\u4e00-\u9fff]{0,6}?)\s*[\u4e00-\u9fff]{0,8}?(?:后|之后|了)\s*|(?:推送|上传|提交|发布|执行)\s*(?:完成|结束|完|好)\s*(?:了|之?后)?/;
|
|
40
45
|
const ACTION_RE = ACTION_WORDS_RE;
|
|
41
46
|
const PLAN_RE = /方案|建议|评估|分析|设计|规划|计划|给出|提供/;
|
|
42
47
|
const STRONG_EXEC_RE = STRONG_EXEC_LEX;
|
|
@@ -221,6 +226,8 @@ function classifyClause(clause) {
|
|
|
221
226
|
let type = "info";
|
|
222
227
|
let ambiguous = false;
|
|
223
228
|
|
|
229
|
+
// 2026-08-31(柱子 C):条件句优先判定——"推送完成后/等 X 后"=依赖型分点,不产生独立授权
|
|
230
|
+
//(必须在动作词判定之前:否则"完成后"被当执行分点,其 scope 污染授权池——分点失效根因)
|
|
224
231
|
if (tagType) {
|
|
225
232
|
if (tagType === "execute" && hasQuestion) {
|
|
226
233
|
type = "ambiguous";
|
|
@@ -232,6 +239,10 @@ function classifyClause(clause) {
|
|
|
232
239
|
type = "question";
|
|
233
240
|
} else if (isStatusSignal(text)) {
|
|
234
241
|
type = "status";
|
|
242
|
+
} else if (CONDITIONAL_RE.test(text)) {
|
|
243
|
+
// 2026-08-31(柱子 C):条件句——"推送完成后/等 X 后"=依赖型分点,不产生独立授权/scope
|
|
244
|
+
//(若按动作词判 execute,其"推送"字眼将污染 git 授权池——分点失效根因;宁拦不放)
|
|
245
|
+
type = "conditional";
|
|
235
246
|
} else if (hasDefer && hasAction) {
|
|
236
247
|
type = "plan";
|
|
237
248
|
} else if (hasAction && hasPlan && !SPECIFIC_ACTION_RE.test(text) && !(PLAN_EXECUTING_RE.test(text) && !PLAN_REQUEST_RE.test(text))) {
|
package/lib/core/lexicon.js
CHANGED
|
@@ -28,7 +28,7 @@ export const PLAN_ONLY_RE = /确认方案|确认理解|确认方向|确认无误
|
|
|
28
28
|
// 注意:单一表同时服务"意图判定"(这句话是不是执行分点)与"ask 结果批准判定"(选择=批准),
|
|
29
29
|
// 语义一致,不另分裂;新增动作词只改这里。
|
|
30
30
|
export const ACTION_WORDS_RE =
|
|
31
|
-
/执行|跑|落|落盘|认可|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)
|
|
31
|
+
/执行|跑|落|落盘|认可|开始|写入|写(?:下|好|完|成|作|文件)?|保存|另存为|删除|修改|改(?:为|成|动|一下|进|善)?|补(?:上|齐|全|充|写)?|修(?:改|复|一下)?|做(?:好|完|一下)?|创建|建(?:设|立|好|一下|个|一个)?|安装|发布|下载|提交|推送|push|运行|修复|替换|重建|启动|重启|停止|卸载|开始改|开始写|改进|优化|增强|完善|升级|迁移|整理|调整|实现|实施|添加|增加|补充|重写|改造|调试|排查|处理|解决|继续|推进|更新|部署|核实|梳理|诊断|对齐|跟进|落实|设计并实现|调查|开工|动手|进行|完成|选择|采纳|选定|产出|编写|撰写|生成|审查|阅读|查看|核对|检查|审阅|复核|查阅|验证|对照|调研|研究|转化|转换|读取|读(?:出|一下|一遍|完|出来)?|展示|打开|提取|还原|导入|导出|清理|移除|清空|残留|合入|合并|并入|融合|整合|起草|开展|搭建|重构|兼容|投产|校验|核验|过一遍|跑一遍|复验/i;
|
|
32
32
|
|
|
33
33
|
// 强执行语(直接执行指令,不等授权确认——用于意图置信提升)
|
|
34
34
|
export const STRONG_EXEC_RE =
|
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)
|
|
@@ -147,6 +151,7 @@ export function getSessionState(state, sessionId) {
|
|
|
147
151
|
id: key,
|
|
148
152
|
ruleStats: {}, // C2:每规则 detected/suppressed/injected 统计(会话级,跨回合累计)
|
|
149
153
|
manualReadSeen: false,
|
|
154
|
+
lastQueryTurn: -1, // 规则 5/31(2026-09-01):最近一次查询类工具调用所在回合号(判定"近 3 回合有据")
|
|
150
155
|
lastUserText: "",
|
|
151
156
|
selfCertCount: new Map(),
|
|
152
157
|
authorizations: [],
|
|
@@ -254,6 +259,8 @@ export function freshTurn() {
|
|
|
254
259
|
firstToolName: "",
|
|
255
260
|
skillNames: [],
|
|
256
261
|
toolNames: [],
|
|
262
|
+
cardHits: [], // 0.5.15:本回合 guard 命中缓冲(回合末裁决卡片数据源)
|
|
263
|
+
lastAssistantMessageId: "", // 0.5.15:本回合最后 assistant 消息 id(卡片索引锚点)
|
|
257
264
|
commands: [],
|
|
258
265
|
reasoningText: "",
|
|
259
266
|
// 规则 22 粒度升级(2026-08-24):本回合已获授权范围(execute 子句 + ask 授权)
|
|
@@ -322,3 +329,34 @@ export function reloadRules(state) {
|
|
|
322
329
|
export function findConfig(state, ruleId) {
|
|
323
330
|
return state.configs.find((c) => String(c.ruleId) === String(ruleId));
|
|
324
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
|
+
}
|
package/lib/core/text-detect.js
CHANGED
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
TERM_EXPLANATION_RE,
|
|
12
12
|
SUGGEST_RE,
|
|
13
13
|
isNegatingSuggestion,
|
|
14
|
-
isQuoteOrParaphraseContext
|
|
14
|
+
isQuoteOrParaphraseContext,
|
|
15
|
+
isReadOnlyTool
|
|
15
16
|
} from "./patterns.js";
|
|
16
17
|
import { detectOverengineeringText } from "./overengineering.js";
|
|
17
18
|
|
|
@@ -50,9 +51,16 @@ const SELF_CERT_HINTS = {
|
|
|
50
51
|
"19": { re: /学到新.*DSH|踩到新坑|踩坑.*沉淀|手册有误|知识必沉淀/, reason: "请按规则 19 自证:按①-⑧沉淀并当次汇报正文同步位置" },
|
|
51
52
|
"28": { re: /新建.*文件|放入.*目录|工作区.*归类/, reason: "请按规则 28 自证:新文件按工作区目录索引归类,不确定则查 README 或放 _inbox" },
|
|
52
53
|
"29": { re: /依赖闭包|node_modules.*验证|动过.*node_modules/, reason: "请按规则 29 自证:重启前验证依赖闭包(关键包非空/顶层 junction 完整/无 dangling)" },
|
|
53
|
-
"30": { re: /变更验证|验证证据|不破坏.*依赖/, reason: "请按规则 30 自证:执行前证明不破坏依赖+已授权,完成后附验证证据" }
|
|
54
|
+
"30": { re: /变更验证|验证证据|不破坏.*依赖/, reason: "请按规则 30 自证:执行前证明不破坏依赖+已授权,完成后附验证证据" },
|
|
55
|
+
// 规则 31(2026-09-01 用户拍板):提到"撞墙/盲试"类表述 → D 级自证:写明验证目标→撞了什么/为什么撞→结论来源
|
|
56
|
+
"31": { re: /撞(?:了)?南墙|撞墙|盲试|乱撞|连续盲试/, reason: "规则 31 自证:请写明「验证目标→撞了什么/为什么撞→结论来源(手册位置/源码行号)」;不连续盲试" }
|
|
54
57
|
};
|
|
55
58
|
|
|
59
|
+
// 规则 31(2026-09-01 用户拍板):内部文档引用锚点词("文档"仅在后随"写了/记载…"语境命中,防泛化误报)
|
|
60
|
+
const INTERNAL_REF_RE = /(?:手册|踩坑\s*\d+|条款\s*\d+|第\s*\d+\s*章|SKILL\.md|AGENTS\.md|源码|README|文档[^\n]{0,12}(?:写了|记载|记录|写明|说明))/i;
|
|
61
|
+
// 验证目标启发式:思维链含这些词 → 大概率是有序查证(防规则 31③误报)
|
|
62
|
+
const VERIFY_INTENT_RE = /(?:验证|查证|核对|目标|交叉|对照|确认)/;
|
|
63
|
+
|
|
56
64
|
/** 简单判断一段文本是否以英文为主 */
|
|
57
65
|
function isMostlyEnglish(text) {
|
|
58
66
|
if (!text) return false;
|
|
@@ -157,19 +165,56 @@ export function detectViolations({ configs, session, text, reasoningText = "", m
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
const sourceCfg = byId.get("5");
|
|
160
|
-
if (sourceCfg &&
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
168
|
+
if (sourceCfg && !SOURCE_MARK.test(text)) {
|
|
169
|
+
if (URL_RE.test(text)) {
|
|
170
|
+
hits.push({
|
|
171
|
+
ruleId: "5",
|
|
172
|
+
title: sourceCfg.title,
|
|
173
|
+
kind: "correct",
|
|
174
|
+
reason: "回答包含 URL 但未标注出处/来源"
|
|
175
|
+
});
|
|
176
|
+
} else if (INTERNAL_REF_RE.test(text) && !isQuoteOrParaphraseContext(text, INTERNAL_REF_RE)) {
|
|
177
|
+
// 规则 5 扩展(2026-09-01 用户拍板):内部文档引用(手册/踩坑/条款/源码…)须有依据——
|
|
178
|
+
// 近 3 回合无对应 read/grep 时提示(B 级:留痕+注入,不拦截)。
|
|
179
|
+
const curTurn = session.turn.number || 0;
|
|
180
|
+
const lastQuery = session.lastQueryTurn ?? -1;
|
|
181
|
+
if (lastQuery < 0 || curTurn - lastQuery > 3) {
|
|
182
|
+
hits.push({
|
|
183
|
+
ruleId: "5",
|
|
184
|
+
title: sourceCfg.title,
|
|
185
|
+
kind: "correct",
|
|
186
|
+
reason: "回答引用内部文档(手册/踩坑/条款/源码等)但近 3 回合无对应 read/grep——请标注手册位置或删去断言"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 规则 31(2026-09-01 用户拍板):同回合同一只读工具 ≥3 次且思维链无验证目标语 → 提示(B 级:留痕+注入)
|
|
193
|
+
const rule31Cfg = byId.get("31");
|
|
194
|
+
if (rule31Cfg) {
|
|
195
|
+
const counts = new Map();
|
|
196
|
+
const pending = session.turn?.pendingToolCalls;
|
|
197
|
+
if (pending && typeof pending.values === "function") {
|
|
198
|
+
for (const pc of pending.values()) {
|
|
199
|
+
if (pc && pc.name && isReadOnlyTool(pc.name, pc.args)) {
|
|
200
|
+
counts.set(pc.name, (counts.get(pc.name) || 0) + 1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const repeated = [...counts.entries()].find(([, c]) => c >= 3);
|
|
205
|
+
if (repeated && !VERIFY_INTENT_RE.test(reasoningText)) {
|
|
206
|
+
hits.push({
|
|
207
|
+
ruleId: "31",
|
|
208
|
+
title: rule31Cfg.title,
|
|
209
|
+
kind: "correct",
|
|
210
|
+
reason: `规则 31:回合同一只读工具(${repeated[0]})调用 ${repeated[1]} 次且思维链无验证目标语——有序交叉验证请忽略,连续盲试请先写明「要验证什么」`
|
|
211
|
+
});
|
|
212
|
+
}
|
|
167
213
|
}
|
|
168
214
|
|
|
169
215
|
const langCfg = byId.get("11");
|
|
170
216
|
const visibleEnglish = isMostlyEnglish(text);
|
|
171
|
-
const reasoningEnglish = isMostlyEnglish(reasoningText);
|
|
172
|
-
if (langCfg && session.lastUserText && CJK_RE.test(session.lastUserText) && (visibleEnglish || reasoningEnglish)) {
|
|
217
|
+
const reasoningEnglish = isMostlyEnglish(reasoningText); if (langCfg && session.lastUserText && CJK_RE.test(session.lastUserText) && (visibleEnglish || reasoningEnglish)) {
|
|
173
218
|
hits.push({
|
|
174
219
|
ruleId: "11",
|
|
175
220
|
title: langCfg.title,
|
|
@@ -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") {
|
|
@@ -639,6 +659,8 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
639
659
|
}
|
|
640
660
|
}
|
|
641
661
|
if (isManualReadTool(toolName, args)) s.manualReadSeen = true;
|
|
662
|
+
// 规则 5/31 扩展(2026-09-01):查询类工具调用记下回合号("近 3 回合有据"判定)
|
|
663
|
+
if (isReadOnlyTool(toolName, args)) s.lastQueryTurn = s.turn.number;
|
|
642
664
|
if (toolName === "skill" && args?.name) s.turn.skillNames.push(args.name);
|
|
643
665
|
s.turn.toolNames.push(toolName);
|
|
644
666
|
if (state.taskContract?.taskContractEnabled) {
|
|
@@ -993,6 +1015,11 @@ export function handleSessionEvent(ctx, session, event) {
|
|
|
993
1015
|
}
|
|
994
1016
|
if (event.type === "assistant/message") {
|
|
995
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
|
+
}
|
|
996
1023
|
// v0.5.7 第 0 站:注入轮(无真实用户消息)不检测不投递——这是乒乓循环的根治:
|
|
997
1024
|
// 引擎自己的注入 → 我的回复 → 再检测 的燃料被掐断(实测 2026-08-26 12:37 六圈即此循环)。
|
|
998
1025
|
if (!shouldDetectTurn({ realUserSeen: s.turn.realUserSeen })) return;
|
|
@@ -1570,6 +1597,18 @@ export function apply(ctx) {
|
|
|
1570
1597
|
errId: hit.errId,
|
|
1571
1598
|
session: sessionIdOfExec(exec) // P0-4:deny 审计补 session(2026-08-24 事故复盘:归属会话靠猜)
|
|
1572
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
|
+
})();
|
|
1573
1612
|
state.lastActive = [{ ruleId: hit.ruleId, title: hit.title, reason: hit.reason }];
|
|
1574
1613
|
// 规则 1 计数解耦(2026-08-24,P1-7):引擎 deny 不计入“同工具连续失败”
|
|
1575
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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// release-plugin.mjs —— DSH 插件全渠道一键发布(npm + git + GitHub Release)
|
|
2
|
+
// 用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]
|
|
3
|
+
// - 插件名从 scripts/plugins.json 清单解析(规则 26 ⑤);或直接给目录路径
|
|
4
|
+
// - 版本号省略时自动 patch+1(如 1.4.7 -> 1.4.8)
|
|
5
|
+
// - 自动同步 README 徽章 version-X.Y.Z
|
|
6
|
+
// - --sync-profile:发布成功后自动更新 profiles/<profile>/pnpm-workspace.yaml 豁免名单
|
|
7
|
+
// (备份原文件)+ 运行全量装配审计(写 .dsh 需以 danger-full-access 运行本脚本)
|
|
8
|
+
// 前置:npm 已认证、gh 已认证、git 已配置。
|
|
9
|
+
// 网络:默认直连(2026-08-27 实测 npm registry / github.com / api.github.com 直连可用,无需代理;
|
|
10
|
+
// 代理挂掉时不再 ECONNREFUSED)。需要代理的环境设 DSH_RELEASE_PROXY=http://127.0.0.1:7890。
|
|
11
|
+
// 步骤:版本 bump -> 测试 -> npm pack -> npm publish -> git commit+push -> gh release(带 tgz asset)
|
|
12
|
+
// 安全:token 经环境变量注入,不在命令文本/日志中打印
|
|
13
|
+
import { execSync } from "node:child_process";
|
|
14
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const PROXY = process.env.DSH_RELEASE_PROXY || ""; // 默认直连;需要代理时显式设置
|
|
19
|
+
/** 代理前缀(命令文本):空 = 直连 */
|
|
20
|
+
const proxyPrefix = () => (PROXY ? `set HTTPS_PROXY=${PROXY}&& set HTTP_PROXY=${PROXY}&& ` : "");
|
|
21
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const WORKSPACE = join(SCRIPT_DIR, "..");
|
|
23
|
+
|
|
24
|
+
function run(cmd, opts = {}) {
|
|
25
|
+
console.log(`> ${cmd.slice(0, 120)}${cmd.length > 120 ? "..." : ""}`);
|
|
26
|
+
return execSync(cmd, { stdio: "inherit", encoding: "utf8", shell: true, ...opts });
|
|
27
|
+
}
|
|
28
|
+
function quiet(cmd) {
|
|
29
|
+
try {
|
|
30
|
+
return execSync(cmd, { encoding: "utf8", shell: true, stdio: "pipe" }).toString().trim();
|
|
31
|
+
} catch {
|
|
32
|
+
return "";
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function fail(msg) {
|
|
36
|
+
console.error(`\n[发布中止] ${msg}`);
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── 0. 解析目标(清单名 或 目录路径)────────────────────────────
|
|
41
|
+
const args = process.argv.slice(2);
|
|
42
|
+
const syncProfile = args.includes("--sync-profile");
|
|
43
|
+
const dryRun = args.includes("--dry-run"); // 2026-08-29:发布前预览(只推导+打印,不改文件)
|
|
44
|
+
const target = args.find((a) => !a.startsWith("--")) || "";
|
|
45
|
+
let manifest = null;
|
|
46
|
+
let entry = null;
|
|
47
|
+
let dir = null;
|
|
48
|
+
try {
|
|
49
|
+
manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
|
|
50
|
+
entry = manifest.plugins.find((p) => p.name === target) || null;
|
|
51
|
+
} catch {
|
|
52
|
+
// 清单缺失时仅支持目录路径
|
|
53
|
+
}
|
|
54
|
+
if (entry) {
|
|
55
|
+
dir = resolve(WORKSPACE, entry.dir);
|
|
56
|
+
} else if (target) {
|
|
57
|
+
dir = resolve(target);
|
|
58
|
+
} else {
|
|
59
|
+
fail("用法:node release-plugin.mjs <插件名|插件目录> [版本号] [--sync-profile]");
|
|
60
|
+
}
|
|
61
|
+
if (!existsSync(join(dir, "package.json"))) fail(`目录不存在或不是插件包:${dir}`);
|
|
62
|
+
|
|
63
|
+
// ── 0. 读取包信息 ────────────────────────────────────────────────
|
|
64
|
+
const pkgPath = join(dir, "package.json");
|
|
65
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
66
|
+
const name = pkg.name;
|
|
67
|
+
const oldVer = pkg.version;
|
|
68
|
+
// 版本号 = 非 -- 开头的参数(排除目标名本身),位置任意
|
|
69
|
+
// 2026-08-29 修复(0.5.11-pre 体系):oldVer 为 -pre/-rc 等预发布后缀时——
|
|
70
|
+
// ① 发布语义 = 剥后缀发正式版(0.5.11-pre → 发布 0.5.11,不是 +1);
|
|
71
|
+
// ② 自动推导不再 split(".") 直接 Number("11-pre" 会得 NaN——旧 bug)。
|
|
72
|
+
// 显式传参优先;且显式传参如果是 -pre 形态(意外),本发布不剥(警告留痕)。
|
|
73
|
+
const nextVer = args.find((a) => !a.startsWith("--") && a !== target) || (() => {
|
|
74
|
+
// 预发布后缀(-pre/-rc)→ 剥后缀发正式版(0.5.11-pre → 0.5.11)
|
|
75
|
+
if (/-(?:pre|rc|beta|alpha)(?:[.\d]*)$/i.test(oldVer)) return oldVer.replace(/-.*$/, "");
|
|
76
|
+
// 纯数字 → +1(历史行为)
|
|
77
|
+
const [maj, min, pat] = oldVer.split(".").map(Number);
|
|
78
|
+
if (Number.isNaN(pat)) fail(`无法解析版本号:${oldVer}`);
|
|
79
|
+
return `${maj}.${min}.${pat + 1}`;
|
|
80
|
+
})();
|
|
81
|
+
const repoOverride = entry?.repo || null;
|
|
82
|
+
console.log(`\n=== 发布 ${name}: ${oldVer} -> ${nextVer} ===\n`);
|
|
83
|
+
|
|
84
|
+
// ── 0.5 本地配套包版本一致性(防止 host/client 版本错位)──────────
|
|
85
|
+
function checkDependencyPair() {
|
|
86
|
+
if (!entry?.dependsOn || !manifest) return;
|
|
87
|
+
for (const depName of entry.dependsOn) {
|
|
88
|
+
const depEntry = manifest.plugins.find((p) => p.name === depName);
|
|
89
|
+
const depDir = depEntry ? resolve(WORKSPACE, depEntry.dir) : null;
|
|
90
|
+
if (!depDir || !existsSync(join(depDir, "package.json"))) {
|
|
91
|
+
console.log(`(未找到依赖 ${depName} 的本地包,跳过版本一致性检查)`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const depPkg = JSON.parse(readFileSync(join(depDir, "package.json"), "utf8"));
|
|
95
|
+
const range = pkg.dependencies?.[depName];
|
|
96
|
+
if (!range) continue;
|
|
97
|
+
const m = range.match(/^\^(\d+)\.(\d+)\.(\d+)/);
|
|
98
|
+
if (!m) {
|
|
99
|
+
console.log(`(依赖 ${depName} 范围 ${range} 不是简单 ^x.y.z,跳过自动校验)`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const v = depPkg.version.split(".").map(Number);
|
|
103
|
+
const ok = v[0] === +m[1] && (v[1] > +m[2] || (v[1] === +m[2] && v[2] >= +m[3]));
|
|
104
|
+
if (!ok) fail(`${name} 声明依赖 ${depName}@${range},但本地 ${depName} 是 ${depPkg.version}`);
|
|
105
|
+
console.log(`[依赖一致性] ${name} -> ${depName} ${depPkg.version} ✓`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
checkDependencyPair();
|
|
109
|
+
|
|
110
|
+
// ── 1. 前置检查 ──────────────────────────────────────────────────
|
|
111
|
+
if (!/^\d+\.\d+\.\d+$/.test(nextVer)) fail(`版本号格式错误:${nextVer}`);
|
|
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");
|
|
116
|
+
if (!whoami) fail("npm 未认证(npm whoami 失败)");
|
|
117
|
+
|
|
118
|
+
// ── 1.5 DSH 兼容预检(发布前防“升级后插件不兼容”)──────────────
|
|
119
|
+
const compatScript = join(SCRIPT_DIR, "check-dsh-compat.mjs");
|
|
120
|
+
if (existsSync(compatScript)) {
|
|
121
|
+
console.log("\n=== DSH 兼容预检 ===");
|
|
122
|
+
run(`node "${compatScript}" "${dir}"`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── 1.6 profile dump-config 冒烟(确认 DSH 可导出当前 profile 配置)──
|
|
126
|
+
function findDshBin() {
|
|
127
|
+
const candidates = [process.env.DSH_BIN, "D:/npm-global/dsh.cmd", "D:/npm-global/dsh", "dsh"].filter(Boolean);
|
|
128
|
+
for (const c of candidates) {
|
|
129
|
+
if (quiet(`where ${c}`)) return c;
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
const dshCmd = findDshBin();
|
|
134
|
+
if (dshCmd) {
|
|
135
|
+
const dump = quiet(`"${dshCmd}" --profile ${manifest?.profile || "web"} --dump-config`);
|
|
136
|
+
if (dump && !/error|Error|ENOENT/i.test(dump)) {
|
|
137
|
+
console.log("(profile dump-config 冒烟通过:DSH 配置可正常导出)");
|
|
138
|
+
} else {
|
|
139
|
+
console.log("(profile dump-config 冒烟未通过:请人工确认 DSH 可用后发布)");
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
console.log("(未找到 dsh 命令,跳过 profile dump-config 冒烟)");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── 2. 版本 bump(package.json + README 徽章)────────────────────
|
|
146
|
+
if (dryRun) {
|
|
147
|
+
console.log(`[DRY-RUN] 不修改任何文件。`);
|
|
148
|
+
console.log(`[DRY-RUN] oldVer=${oldVer} → nextVer=${nextVer}`);
|
|
149
|
+
const rd = join(dir, "README.md");
|
|
150
|
+
if (existsSync(rd)) {
|
|
151
|
+
const t = readFileSync(rd, "utf8");
|
|
152
|
+
const oldBadgeVer = `version-${oldVer}`;
|
|
153
|
+
const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`;
|
|
154
|
+
const nextUrl = `version-${nextVer}`;
|
|
155
|
+
const replaced = t.replaceAll(oldBadgeVer, nextUrl).replaceAll(oldBadgeUrl, nextUrl);
|
|
156
|
+
console.log(`[DRY-RUN] README 徽章将变为: ${replaced.match(/version-[^\s]+/)?.[0] || "(未命中,检查徽章形态)"}`);
|
|
157
|
+
}
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
writeFileSync(pkgPath, readFileSync(pkgPath, "utf8").replace(`"version": "${oldVer}"`, `"version": "${nextVer}"`));
|
|
161
|
+
for (const readme of ["README.md", "README.en.md"]) {
|
|
162
|
+
const rp = join(dir, readme);
|
|
163
|
+
if (existsSync(rp)) {
|
|
164
|
+
const text = readFileSync(rp, "utf8");
|
|
165
|
+
// 2026-08-29 修复:徽章 URL 编码把 `0.5.11-pre` 转成 `0.5.11--pre`(`-` 被 shields 双横线),
|
|
166
|
+
// 旧正则 `version-0.5.11-pre` 匹配不到 `version-0.5.11--pre` → 徽章漏改(㉙ 机器根因之一)。
|
|
167
|
+
// replaceAll 用字面串(非正则),无需转义。
|
|
168
|
+
const oldBadgeVer = `version-${oldVer}`; // JSON 形态:version-0.5.11-pre
|
|
169
|
+
const oldBadgeUrl = `version-${oldVer.replace(/-/g, "--")}`; // URL 形态:version-0.5.11--pre
|
|
170
|
+
const nextUrl = `version-${nextVer}`;
|
|
171
|
+
const replaced = text
|
|
172
|
+
.replaceAll(oldBadgeVer, nextUrl)
|
|
173
|
+
.replaceAll(oldBadgeUrl, nextUrl);
|
|
174
|
+
if (replaced !== text) writeFileSync(rp, replaced);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
console.log("版本已 bump(package.json + README 徽章)");
|
|
178
|
+
|
|
179
|
+
// ── 3. 测试(规则 23:发布前运行时验证)─────────────────────────
|
|
180
|
+
if (pkg.scripts && pkg.scripts.test) {
|
|
181
|
+
console.log("\n=== 运行测试 ===");
|
|
182
|
+
run(`cd /d "${dir}" && npm test --prefix "${dir}"`);
|
|
183
|
+
} else {
|
|
184
|
+
console.log("(无 test 脚本,跳过)");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── 4. pack + publish ────────────────────────────────────────────
|
|
188
|
+
console.log("\n=== npm pack ===");
|
|
189
|
+
run(`cd /d "${dir}" && npm pack --pack-destination .`);
|
|
190
|
+
const tgz = `${name}-${nextVer}.tgz`;
|
|
191
|
+
if (!existsSync(join(dir, tgz))) fail(`打包产物缺失:${tgz}`);
|
|
192
|
+
|
|
193
|
+
console.log("\n=== npm publish ===");
|
|
194
|
+
run(`cd /d "${dir}" && ${proxyPrefix()}npm publish ${tgz}`);
|
|
195
|
+
|
|
196
|
+
// ── 5. git commit + push(token 经环境变量注入 URL,命令文本不含密钥明文)─────
|
|
197
|
+
console.log("\n=== git commit + push ===");
|
|
198
|
+
// 2026-08-25 修复 pushUrl 双重拼接:origin 可能是 ssh(git@github.com:)、https(https://github.com/)
|
|
199
|
+
// 或裸路径(user/repo)三种形态——统一归一化为 "owner/repo" 路径再拼 token URL(此前 https origin 未剥前缀
|
|
200
|
+
// 导致 "https://github.com/https://github.com/..." 双重 URL,push 404)
|
|
201
|
+
const repo = (repoOverride || quiet(`cd /d "${dir}" && git remote get-url origin`))
|
|
202
|
+
.replace(/^git@github\.com:/, "")
|
|
203
|
+
.replace(/^https?:\/\/github\.com\//, "")
|
|
204
|
+
.replace(/\.git$/, "");
|
|
205
|
+
const token = quiet("gh auth token");
|
|
206
|
+
if (!token) fail("无法获取 gh token");
|
|
207
|
+
// 与 v3.72 同款通道:token 拼进 HTTPS URL 直推(避开沙箱下 msys 凭据管道的 EPERM)
|
|
208
|
+
const pushUrl = `https://jilian-dsh:${token}@github.com/${repo}.git`;
|
|
209
|
+
// 2026-08-31 修复:repoRoot 实测 git 仓库根(插件目录可能只是子目录,如 rules-manager 在 oss 仓库内);
|
|
210
|
+
// stageSpec = 插件目录相对仓库根路径(防 git add -A 误带仓库内无关改动/未跟踪物)
|
|
211
|
+
const repoRoot = quiet(`cd /d "${dir}" && git rev-parse --show-toplevel`).trim();
|
|
212
|
+
const relDir = relative(repoRoot, dir).split(/[\\/]/).join("/");
|
|
213
|
+
const stageSpec = relDir && relDir !== "." ? relDir : "";
|
|
214
|
+
// 2026-08-29 防漏机检(0.5.10 git 欠账事故:git add -u 只更新已跟踪文件,未跟踪新文件
|
|
215
|
+
// (npm pack 按目录打包、天然包含)会漏进 git 提交 → git 与 npm 内容不一致)。
|
|
216
|
+
// 现在:提交前检测未跟踪文件并打印名单,改用 add -A 一并提交(.gitignore 已排除杂质)。
|
|
217
|
+
const untrackedList = quiet(`cd /d "${repoRoot}" && git status --porcelain -- ${stageSpec}`)
|
|
218
|
+
.split("\n").filter((l) => l.startsWith("??")).map((l) => l.slice(3));
|
|
219
|
+
if (untrackedList.length > 0) {
|
|
220
|
+
console.log(`(未跟踪文件 ${untrackedList.length} 个将一并提交:${untrackedList.slice(0, 6).join(", ")}${untrackedList.length > 6 ? " 等" : ""})`);
|
|
221
|
+
}
|
|
222
|
+
run(`cd /d "${repoRoot}" && ${proxyPrefix()}git add -A -- ${stageSpec} && git -c core.autocrlf=false commit -m "release: ${name} v${nextVer}" || exit 0`);
|
|
223
|
+
run(`cd /d "${repoRoot}" && ${proxyPrefix()}git push "${pushUrl}" HEAD`);
|
|
224
|
+
|
|
225
|
+
// ── 6. GitHub Release(带正式 tgz asset,规则 26;清单标记 skipRelease 的包不建)─────
|
|
226
|
+
if (entry?.skipRelease) {
|
|
227
|
+
console.log("(清单标记 skipRelease,跳过 GitHub Release)");
|
|
228
|
+
} else {
|
|
229
|
+
console.log("\n=== GitHub Release ===");
|
|
230
|
+
const notesFile = join(process.env.TEMP || ".", `notes-${name}-${nextVer}.md`);
|
|
231
|
+
writeFileSync(notesFile, `## v${nextVer}\n\nRelease generated by scripts/release-plugin.mjs\n`);
|
|
232
|
+
// 必须在插件目录(git 仓库)内运行:gh release 内部会做 git 检查
|
|
233
|
+
run(`cd /d "${dir}" && ${proxyPrefix()}gh release create v${nextVer} "${join(dir, tgz)}" --title "${name} v${nextVer}" --notes-file "${notesFile}"`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── 7. 本机 profile 同步(--sync-profile:豁免名单 + 装配审计;需 danger-full-access 运行)──
|
|
237
|
+
if (syncProfile) {
|
|
238
|
+
console.log("\n=== 本机 profile 同步 ===");
|
|
239
|
+
let manifest = null;
|
|
240
|
+
try {
|
|
241
|
+
manifest = JSON.parse(readFileSync(join(SCRIPT_DIR, "plugins.json"), "utf8"));
|
|
242
|
+
} catch {
|
|
243
|
+
manifest = { profile: "web" };
|
|
244
|
+
}
|
|
245
|
+
const profileDir = resolve(WORKSPACE, "..", ".dsh", "profiles", manifest.profile || "web");
|
|
246
|
+
const wsYaml = join(profileDir, "pnpm-workspace.yaml");
|
|
247
|
+
if (existsSync(wsYaml)) {
|
|
248
|
+
const raw = readFileSync(wsYaml, "utf8");
|
|
249
|
+
const lineRe = new RegExp(`^( - ${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}@[^\\n]*)$`, "m");
|
|
250
|
+
const m = raw.match(lineRe);
|
|
251
|
+
if (m && !m[1].includes(nextVer)) {
|
|
252
|
+
const bakDir = join(profileDir, "..", ".backups");
|
|
253
|
+
mkdirSync(bakDir, { recursive: true });
|
|
254
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
255
|
+
const bak = join(bakDir, `pnpm-workspace-${stamp}.yaml`);
|
|
256
|
+
copyFileSync(wsYaml, bak);
|
|
257
|
+
const updated = raw.replace(lineRe, `${m[1]} || ${nextVer}`);
|
|
258
|
+
writeFileSync(wsYaml, updated);
|
|
259
|
+
console.log(`豁免名单已追加 ${name}@${nextVer}(备份 ${bak})`);
|
|
260
|
+
} else if (!m) {
|
|
261
|
+
console.log(`(豁免名单无 ${name} 条目,跳过)`);
|
|
262
|
+
} else {
|
|
263
|
+
console.log(`(豁免名单已含 ${nextVer},跳过)`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
// 全量装配审计(规则 27)
|
|
267
|
+
const auditScript = join(SCRIPT_DIR, "..", "projects", "oss", "dsh-rule-engine", "scripts", "audit-mount-consistency.mjs");
|
|
268
|
+
if (existsSync(auditScript)) {
|
|
269
|
+
const out = quiet(`node "${auditScript}" --profile ${manifest.profile || "web"}`);
|
|
270
|
+
const pass = /MOUNT CONSISTENT/.test(out);
|
|
271
|
+
console.log(out.split("\n").filter((l) => /RESULT|DUPLICATES|MOUNT|summary/.test(l)).join("\n"));
|
|
272
|
+
if (!pass) fail("装配审计未通过(MOUNT CONSISTENT 未出现),请先处理再重启 DSH");
|
|
273
|
+
console.log("装配审计 MOUNT CONSISTENT ✓");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
console.log(`\n=== 发布完成:${name} v${nextVer} ===`);
|
|
278
|
+
console.log(`npm: ${name}@${nextVer}`);
|
|
279
|
+
console.log(`release: https://github.com/${repo}/releases/tag/v${nextVer}`);
|