dsh-log-contract 0.3.13 → 0.3.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 +46 -17
- package/README.zh.md +48 -15
- package/bin/dsh-log-contract.mjs +77 -9
- package/docs/CONTRACTS.md +72 -22
- package/lib/archaeology.js +2 -2
- package/lib/checks.js +74 -24
- package/lib/contracts.js +84 -16
- package/lib/host-probes.js +147 -0
- package/lib/index.js +1 -0
- package/lib/log-reader.js +17 -4
- package/lib/prewrite.js +59 -8
- package/lib/repair.js +24 -7
- package/lib/validate.js +56 -5
- package/lib/version-support.js +148 -0
- package/lib/vocab.js +31 -3
- package/package.json +3 -3
package/docs/CONTRACTS.md
CHANGED
|
@@ -3,18 +3,26 @@
|
|
|
3
3
|
> **自动生成**(2026-09-06 起):本文件由 `node scripts/gen-contracts-doc.mjs`
|
|
4
4
|
> 从 `lib/contracts.js` 的 `CONTRACT_RULES` 注册表生成——**勿手改**,规则只增不减,
|
|
5
5
|
> 新增规则后跑一次生成即同步(此前手工维护滞后 15+ 条,外部审计指出)。
|
|
6
|
+
> **漂移闸**:`pnpm check` 会跑 `--check` 逐字节比对,改了注册表没重生成 ⇒ 直接红。
|
|
6
7
|
>
|
|
7
8
|
> DSH 会话日志契约的**可执行 spec**。每条规则在 `lib/checks.js`(逐事件判定)
|
|
8
9
|
> 与 `lib/prewrite.js`(写前校验)中有对应实现;离线体检(`lib/validate.js`)
|
|
9
10
|
> 逐条执行并在最后用官方 `foldSurface` 终验(S8)。
|
|
10
11
|
>
|
|
11
|
-
>
|
|
12
|
-
>
|
|
13
|
-
>
|
|
12
|
+
> 规则来源:早期内部审计发现(59 条)+ 三层契约事故复盘 + 官方源码逐行核对
|
|
13
|
+
> (`@deepseek-ai/dsh-session`,各条出处见下)。后续规则随官方版本演进追加:
|
|
14
|
+
> T3/T4 = 渲染层白屏事故复盘,T5 = malformed turn/end 事故复盘。
|
|
15
|
+
>
|
|
16
|
+
> **版本支持基线(本规则集验证过的版本;权威常量见 `lib/version-support.js` 的 TESTED_BASELINE)**:
|
|
17
|
+
> 宿主 `@deepseek-ai/dsh-session@0.1.5-rc.1` | 会话格式 **v3**(`SESSION_FORMAT_VERSION = 3`)|
|
|
18
|
+
> 声明范围 `^0.1.0-rc.7 || ^0.1.5-rc.1`(可安装 ≠ 逐个验证)| 已知格式 0/1/2/3
|
|
19
|
+
> (0–2 由本包自带 vendored 词表 + `legacyFoldSurface` 支持,与宿主版本无关)。
|
|
20
|
+
> **未验证版本**:宿主比基线新 → 运行时告警;文件格式未知/无法识别 → 报"未验证格式"并**按只读处理**
|
|
21
|
+
> (`prewrite`/`fix` 拒绝,退出码 3);文件版本 > 宿主上限 → `not-assessable`(不是 broken)。
|
|
14
22
|
>
|
|
15
23
|
> 严重度:**error** = 违反即会话不可加载/写入被拒(fail-loud);**warning** = 合法但可疑。
|
|
16
24
|
|
|
17
|
-
## 规则索引(共
|
|
25
|
+
## 规则索引(共 44 条)
|
|
18
26
|
|
|
19
27
|
| id | 严重度 | 层级 | 规则 |
|
|
20
28
|
|---|---|---|---|
|
|
@@ -45,6 +53,9 @@
|
|
|
45
53
|
| T4 | error | engine | step/消息本体 turn 缺失(null/undefined)→ 渲染死循环 |
|
|
46
54
|
| T5 | error | engine | turn/end 必须带 data.reason.kind |
|
|
47
55
|
| E7 | warning | persistence | ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者) |
|
|
56
|
+
| E8 | warning | persistence | 事件信封键白名单(多余键:seed/restore 路径会拒) |
|
|
57
|
+
| E9 | error | persistence | system/message 必须带 plugin source |
|
|
58
|
+
| E10 | error | persistence | request/header 的 data.header 字段约束 |
|
|
48
59
|
| Z3 | warning | framing | 空会话文件(有 header 无事件)显式报出 |
|
|
49
60
|
| P3 | warning | plugin | tool/call ↔ tool/result 配对完整性(考古 B1) |
|
|
50
61
|
| P4 | warning | plugin | tool/result 输出结构可解析(考古 B2) |
|
|
@@ -56,6 +67,9 @@
|
|
|
56
67
|
| Z2 | error | framing | zstd 帧解码失败 = 单帧全损 |
|
|
57
68
|
| W1 | error | engine | wire 流:tool 消息必须跟在带 tool-call 的 assistant 消息之后 |
|
|
58
69
|
| W2 | error | engine | wire 流:user 文本不得插在 tool_calls 与其 tool 结果之间 |
|
|
70
|
+
| G1 | warning | migration | 迁移预检:v0 源文件的 subagent/descriptor.data.version 必须为 3 |
|
|
71
|
+
| G2 | warning | migration | 迁移预检:v0 源文件不得含词表外的历史事件类型(含 ignorable) |
|
|
72
|
+
| G3 | warning | migration | 迁移预检:v0 源 session/title 系列的 messageSeqs 必须引用更早的人类 user/message |
|
|
59
73
|
|
|
60
74
|
## 详细规则
|
|
61
75
|
|
|
@@ -68,8 +82,8 @@
|
|
|
68
82
|
### H2 — header 版本与必填字段
|
|
69
83
|
|
|
70
84
|
- **层级**: persistence | **严重度**: error
|
|
71
|
-
- **出处**: @deepseek-ai/dsh-session lib/index.js:1110-1125
|
|
72
|
-
- **契约**: header.version
|
|
85
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1110-1125;已知格式版本 0/1/2/3(App 2.0.9 内置 v0→v1→v2→v3 迁移,SESSION_FORMAT_VERSION=3)
|
|
86
|
+
- **契约**: header.version 必须为已知受支持版本(0/1/2/3);未知版本报 H2。id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。
|
|
73
87
|
|
|
74
88
|
### R1 — 每行必须是合法 JSON
|
|
75
89
|
|
|
@@ -104,13 +118,13 @@
|
|
|
104
118
|
### S9 — 文件物理序 seq 单调(多写入者交织现场特征)
|
|
105
119
|
|
|
106
120
|
- **层级**: persistence | **严重度**: error
|
|
107
|
-
- **出处**: 2026-08-28
|
|
121
|
+
- **出处**: 2026-08-28 实锤(某真实会话):文件物理序出现回退(大→小→更大);单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出
|
|
108
122
|
- **契约**: 按文件物理行序要求展开后事件 seq 严格单调递增。E2 在排序后检查(loadSessionLog 会 sort),物理序倒退被掩盖;S9 在排序前按行序检查,非单调 = 多写入者/旧光标回放交织的直接现场证据,加载会被拒。
|
|
109
123
|
|
|
110
124
|
### I1 — inbox seed 相对重放(fork 边界孤儿 spliced)
|
|
111
125
|
|
|
112
126
|
- **层级**: engine | **严重度**: error
|
|
113
|
-
- **出处**: @deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28
|
|
127
|
+
- **出处**: @deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤(某两个真实会话):fork 边界 removedCount=1 孤儿
|
|
114
128
|
- **契约**: 从 header.seedLength 起重放 agent/inbox/spliced,next-turn/next-step 双队列;start+removedCount 不得超过队列长、不得产生重复 pending id。fork 时"移除父待处理提示词"的 splice 假设父会话 inbox,子会话 seed 相对空 inbox 上非法 → resume 被拒(invalid persisted inbox splice)。
|
|
115
129
|
|
|
116
130
|
### E3 — type 必须在已知词汇表内(或带 ignorable 标记)
|
|
@@ -129,7 +143,7 @@
|
|
|
129
143
|
|
|
130
144
|
- **层级**: persistence | **严重度**: error
|
|
131
145
|
- **出处**: @deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)
|
|
132
|
-
- **契约**: request/header-delta 与 reason=fallback 的 request/header
|
|
146
|
+
- **契约**: request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式。注意(R-F 订正复核 §1 E5):宿主 Session.append/appendLines 不看 type ⇒ 写入会成功、下一次读取才炸(依据 dsh-session@0.1.5-rc.1 lib/index.js:1170-1210 / persistence-jsonl:3046-3073),不是写入即被拒。
|
|
133
147
|
|
|
134
148
|
### E6 — 消息类事件消息形状
|
|
135
149
|
|
|
@@ -182,8 +196,8 @@
|
|
|
182
196
|
### S8 — 整日志 foldSurface 可重放
|
|
183
197
|
|
|
184
198
|
- **层级**: persistence | **严重度**: error
|
|
185
|
-
- **出处**: @deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface)
|
|
186
|
-
- **契约**:
|
|
199
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface, v3);v0/v1/v2 用本地等价实现 lib/legacy-fold.js(rc.7 lib/index.js:229-455 逐条移植)
|
|
200
|
+
- **契约**: 终验:按被检文件 header.version 选折叠器(v3 → 官方 foldSurface;v0/v1/v2 → 本地 legacyFoldSurface),不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。注意 0.1.5 的官方 foldSurface 是 v3 语义(replace 用 startSeq/endSeq、assistant/message 禁 sourceEventSeqs),对旧格式文件会误报,不可借用。
|
|
187
201
|
|
|
188
202
|
### T1 — token-meter 配对:assistant/message 与 step/end 必须匹配当前打开的 step/start
|
|
189
203
|
|
|
@@ -195,48 +209,66 @@
|
|
|
195
209
|
|
|
196
210
|
- **层级**: engine | **严重度**: error
|
|
197
211
|
- **出处**: @deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)
|
|
198
|
-
- **契约**: token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30
|
|
212
|
+
- **契约**: token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测:某真实会话的 chunk 源引用跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。
|
|
199
213
|
|
|
200
214
|
### T3 — step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用)
|
|
201
215
|
|
|
202
216
|
- **层级**: engine | **严重度**: error
|
|
203
|
-
- **出处**: 复盘 2026-09-02
|
|
204
|
-
- **契约**: 客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 →
|
|
217
|
+
- **出处**: 复盘 2026-09-02 渲染层白屏事故:客户端渲染节点 key = turn:step,冲突 → React 渲染死循环(由离线自查脚本判出)
|
|
218
|
+
- **契约**: 客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(实测:同一 turn 内两个 step/start 复用 step 95/1,一个来自正常轮、一个来自编辑块;后续全量扫描又发现多个同型冲突)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。
|
|
205
219
|
|
|
206
220
|
### T4 — step/消息本体 turn 缺失(null/undefined)→ 渲染死循环
|
|
207
221
|
|
|
208
222
|
- **层级**: engine | **严重度**: error
|
|
209
|
-
- **出处**: 复盘
|
|
210
|
-
- **契约**: 客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 →
|
|
223
|
+
- **出处**: 复盘 2026-09-01 D8 事故:retrace 0.4.17 编辑块 turn:null(离线自查脚本判致命)
|
|
224
|
+
- **契约**: 客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(实测:编辑块的 step/start + marker + step/end 连续若干行 turn 全为 null)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。
|
|
211
225
|
|
|
212
226
|
### T5 — turn/end 必须带 data.reason.kind
|
|
213
227
|
|
|
214
228
|
- **层级**: engine | **严重度**: error
|
|
215
|
-
- **出处**: 官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});
|
|
216
|
-
- **契约**: 官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError →
|
|
229
|
+
- **出处**: 官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});malformed turn/end 事故(2026-09-02,由离线自查脚本判出)
|
|
230
|
+
- **契约**: 官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。实测:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。
|
|
217
231
|
|
|
218
232
|
### E7 — ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)
|
|
219
233
|
|
|
220
234
|
- **层级**: persistence | **严重度**: warning
|
|
221
|
-
- **出处**:
|
|
235
|
+
- **出处**: 对抗性复查 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)
|
|
222
236
|
- **契约**: 未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。
|
|
223
237
|
|
|
238
|
+
### E8 — 事件信封键白名单(多余键:seed/restore 路径会拒)
|
|
239
|
+
|
|
240
|
+
- **层级**: persistence | **严重度**: warning
|
|
241
|
+
- **出处**: @deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:849-861(assertSessionEventEnvelope)+ :1063-1068(唯一调用点=seed 路径);load 路径容忍见行为探针 p5
|
|
242
|
+
- **契约**: 事件对象只允许 7 个信封键(type/seq/time/data/surfaceOp/sourceEventSeqs/ignorable)。独立复核变异 05 指出"宿主拒、旧契约 0 违规";R-D 行为探针进一步订正口径:**load 路径容忍、seed/restore 路径拒** ⇒ warning。
|
|
243
|
+
|
|
244
|
+
### E9 — system/message 必须带 plugin source
|
|
245
|
+
|
|
246
|
+
- **层级**: persistence | **严重度**: error
|
|
247
|
+
- **出处**: @deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:942-944("must have plugin source");角色表 :917-926
|
|
248
|
+
- **契约**: v3 新增的 system/message:role 必须为 system,source.kind 必须为 plugin 且 plugin 非空。实测(变异 03):source.kind='user' 宿主拒、旧契约 0 违规 ⇒ 漏检。
|
|
249
|
+
|
|
250
|
+
### E10 — request/header 的 data.header 字段约束
|
|
251
|
+
|
|
252
|
+
- **层级**: persistence | **严重度**: error
|
|
253
|
+
- **出处**: @deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:231-248(validateSessionEventData:omit header.system / omit empty tools / omit empty adapterDefaults)
|
|
254
|
+
- **契约**: request/header 必须省略 header.system(系统提示改走 system/message)、空 tools、空 adapterDefaults。实测(变异 12):带 header.system 的写入宿主拒、旧契约 0 违规 ⇒ 漏检。
|
|
255
|
+
|
|
224
256
|
### Z3 — 空会话文件(有 header 无事件)显式报出
|
|
225
257
|
|
|
226
258
|
- **层级**: framing | **严重度**: warning
|
|
227
|
-
- **出处**:
|
|
259
|
+
- **出处**: 对抗性复查 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)
|
|
228
260
|
- **契约**: 有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。
|
|
229
261
|
|
|
230
262
|
### P3 — tool/call ↔ tool/result 配对完整性(考古 B1)
|
|
231
263
|
|
|
232
264
|
- **层级**: plugin | **严重度**: warning
|
|
233
|
-
- **出处**:
|
|
265
|
+
- **出处**: 考古方法 §2/§4.2(callId 配对,不可用"上一个 call"推断)
|
|
234
266
|
- **契约**: 每个 tool/call 的 data.callId 必须能在 tool/result 的 data.message.source.callId 中找到配对;孤儿 call(无 result)告警——中断/失败轮次可能产生孤儿(合法但要审计),考古提取将缺该输出。
|
|
235
267
|
|
|
236
268
|
### P4 — tool/result 输出结构可解析(考古 B2)
|
|
237
269
|
|
|
238
270
|
- **层级**: plugin | **严重度**: warning
|
|
239
|
-
- **出处**:
|
|
271
|
+
- **出处**: 考古方法 §2/§4.2(content 递归 text 结构)
|
|
240
272
|
- **契约**: tool/result 的 data.message.content 必须可递归解析(list[dict{type:text,text}] 或等价);不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。
|
|
241
273
|
|
|
242
274
|
### M1 — turn/step 为 null 的 assistant/message 只能 replace,不能 append
|
|
@@ -287,3 +319,21 @@
|
|
|
287
319
|
- **出处**: OpenAI 兼容端点对 tool 消息顺序的严格校验;DSH 序列化器将混合 user 消息展开为 text 在前、tool-result 在后
|
|
288
320
|
- **契约**: 当仍有未满足的 assistant tool-call 时出现 user 文本消息,会产生 [assistant(tool_calls), user(text), tool] 序列,严格端点同样拒绝。
|
|
289
321
|
|
|
322
|
+
### G1 — 迁移预检:v0 源文件的 subagent/descriptor.data.version 必须为 3
|
|
323
|
+
|
|
324
|
+
- **层级**: migration | **严重度**: warning
|
|
325
|
+
- **出处**: @deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1584-1586(assertReleasedEventPayload):data.version !== 3 且源版本 === 0 → SessionFormatUnsupportedMigrationError("uses unsupported descriptor version N");源版本 1/2 时官方提前 return(容忍)
|
|
326
|
+
- **契约**: 文件版本 0 且事件类型为 subagent/descriptor 且 data.version !== 3 → 官方 v0→v1 迁移直接拒绝(消息形如 `subagent/descriptor <seq> uses unsupported descriptor version 2`);源版本 1/2 不受此条约束。
|
|
327
|
+
|
|
328
|
+
### G2 — 迁移预检:v0 源文件不得含词表外的历史事件类型(含 ignorable)
|
|
329
|
+
|
|
330
|
+
- **层级**: migration | **严重度**: warning
|
|
331
|
+
- **出处**: @deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:1580-1583(assertReleasedEventPayload):RELEASED_V0_EVENT_DISPOSITIONS 里没有该 type → "format v0 contains unknown historical event type … migration refuses unknown historical events even when ignorable"
|
|
332
|
+
- **契约**: 文件版本 0 且事件 type 不在官方 v0 dispositions(本包 vendored 为 V0_EVENT_TYPES)内 → 官方迁移拒绝,**即使该事件带 ignorable:true**。E3 的 ignorable 豁免是**读取路径**语义(不改),本条只在迁移预检维度表达。
|
|
333
|
+
|
|
334
|
+
### G3 — 迁移预检:v0 源 session/title 系列的 messageSeqs 必须引用更早的人类 user/message
|
|
335
|
+
|
|
336
|
+
- **层级**: migration | **严重度**: warning
|
|
337
|
+
- **出处**: @deepseek-ai/dsh-session-format-v0-to-v1@0.1.5-rc.2 lib/index.js:2543-2556(assertTitleSources):`session/title` 的 messageSeqs 为空 ⟺ source.kind === "user";每个被引 seq 必须解析到 `user/message` 且其 `data.source.kind === "user"`,否则 "messageSeqs must cite earlier human user/message events" / "must be empty exactly for a user title"
|
|
338
|
+
- **契约**: 文件版本 0 且事件为 session/title 或 session/title-llm-request:messageSeqs 必须是数组;session/title 的"空数组 ⟺ 用户标题"必须成立;每个被引 seq 必须是更早的 user/message 且 source.kind === "user"。违反 → 官方升级到当前格式时拒绝。**应用面说明**:官方 `assertTitleSources` 与 turn/start 状态机同在 `assertReleasedArtifactRelationships`,由 v1→v2 在**变换后的 v1/v2 artifact** 上调用(`dsh-session-format-v1-to-v2/lib/index.js:104`);对 messageSeqs 这类按 seq 索引 + 类型/source 判定的引用,变换保序保类型 ⇒ 在原始 v0 上判是必要条件的近似,实测 281 真实 v0 上 0 误报。
|
|
339
|
+
|
package/lib/archaeology.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-log-contract · lib/archaeology.js
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 会话日志考古 —— 两插件共享:
|
|
5
5
|
* retrace 的考古界面/导出(A1-A4)与 log-contract 的 extract/audit-report
|
|
6
6
|
* (B3/B4)都消费这里的纯函数。**只读不写**(纪律 §8.1)。
|
|
7
7
|
*
|
|
@@ -100,7 +100,7 @@ export function indexToolCalls(events) {
|
|
|
100
100
|
*
|
|
101
101
|
* @param events - 会话事件数组。
|
|
102
102
|
* @param pattern - 命令正则(字符串或 RegExp;字符串按子串匹配,空则全量)。
|
|
103
|
-
* @param opts.minSize - 输出最小字节数过滤(默认 0
|
|
103
|
+
* @param opts.minSize - 输出最小字节数过滤(默认 0;CLI 默认用 50 过滤噪声)。
|
|
104
104
|
* @returns {{ pairs: Array<{ callId, command, text, size }>, matched: number, total: number }}
|
|
105
105
|
*/
|
|
106
106
|
export function extractToolOutputs(events, pattern = '', { minSize = 0 } = {}) {
|
package/lib/checks.js
CHANGED
|
@@ -65,19 +65,51 @@ export function envelopeViolations(event, loc, version) {
|
|
|
65
65
|
out.push(violation('E4', loc, 'sourceEventSeqs 不是 lossless-JSON'));
|
|
66
66
|
}
|
|
67
67
|
if (event.type === 'request/header-delta') {
|
|
68
|
-
out.push(violation('E5', loc, '使用已删除的遗留格式 request/header-delta
|
|
68
|
+
out.push(violation('E5', loc, '使用已删除的遗留格式 request/header-delta——写入会成功(宿主 append 不看 type)、下一次读取才炸;请改用当前格式'));
|
|
69
69
|
}
|
|
70
70
|
if (event.type === 'request/header' && event.data?.reason === 'fallback') {
|
|
71
71
|
out.push(violation('E5', loc, 'request/header 使用已删除的遗留 reason "fallback"'));
|
|
72
72
|
}
|
|
73
|
+
// ── R-G(2026-09-14 独立复核:3 例确证漏检补成候选规则)────────────────────
|
|
74
|
+
// E8 信封键白名单:宿主 `assertSessionEventEnvelope`(@deepseek-ai/dsh-session@0.1.5-rc.1
|
|
75
|
+
// lib/index.js:852-861)逐键 switch,只认 7 个键,其余一律 `invalid event envelope`。
|
|
76
|
+
// 实测变异 05:宿主拒、旧契约 0 违规。
|
|
77
|
+
if (typeof event === 'object' && event !== null) {
|
|
78
|
+
const extra = Object.keys(event).filter((k) => !ENVELOPE_ALLOWED_KEYS.has(k));
|
|
79
|
+
if (extra.length > 0) {
|
|
80
|
+
out.push(violation('E8', loc, `事件信封含多余键 ${extra.join(', ')}——宿主只认 {${[...ENVELOPE_ALLOWED_KEYS].join(',')}}(dsh-session@0.1.5-rc.1 lib/index.js:852-861)`));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// E10 request/header 的 data 字段约束:宿主 `validateSessionEventData`
|
|
84
|
+
// (dsh-session@0.1.5-rc.1 lib/index.js:231-248)——`header.system` 必须省略、
|
|
85
|
+
// 空 tools / 空 adapterDefaults 必须省略。实测变异 12:宿主拒、旧契约 0 违规。
|
|
86
|
+
if (event.type === 'request/header') {
|
|
87
|
+
const header = event.data?.header;
|
|
88
|
+
if (typeof header !== 'object' || header === null || Array.isArray(header)) {
|
|
89
|
+
out.push(violation('E10', loc, 'request/header 的 data.header 必须是对象'));
|
|
90
|
+
} else {
|
|
91
|
+
if (Object.hasOwn(header, 'system')) out.push(violation('E10', loc, 'request/header 必须省略 header.system(系统提示改走 system/message)——宿主 lib/index.js:237'));
|
|
92
|
+
if (Array.isArray(header.tools) && header.tools.length === 0) out.push(violation('E10', loc, 'request/header 必须省略空 tools——宿主 lib/index.js:238'));
|
|
93
|
+
const defaults = header.adapterDefaults;
|
|
94
|
+
if (typeof defaults === 'object' && defaults !== null && !Array.isArray(defaults) && Object.keys(defaults).length === 0) {
|
|
95
|
+
out.push(violation('E10', loc, 'request/header 必须省略空 adapterDefaults——宿主 lib/index.js:239-240'));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
73
99
|
out.push(...messageShapeViolations(event, loc));
|
|
74
100
|
return out;
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
/** 宿主信封键白名单(assertSessionEventEnvelope,dsh-session@0.1.5-rc.1 lib/index.js:852-861)。 */
|
|
104
|
+
export const ENVELOPE_ALLOWED_KEYS = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs', 'ignorable']);
|
|
105
|
+
|
|
77
106
|
/** 镜像官方 assertMessageEventShape(lib/index.js:1242-1266)。 */
|
|
78
107
|
export function messageShapeViolations(event, loc) {
|
|
79
108
|
const type = event.type;
|
|
80
|
-
|
|
109
|
+
// R-G:把 v3 新增的 `system/message` 纳入形状检查(MESSAGE_ROLE_BY_TYPE:system/message → system,
|
|
110
|
+
// dsh-session@0.1.5-rc.1 lib/index.js:917-926;source 要求 :942-944)。旧实现只查 user/assistant/tool
|
|
111
|
+
// ⇒ 实测变异 03(system/message 的 source.kind='user')宿主拒、旧契约 0 违规。
|
|
112
|
+
if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result' && type !== 'system/message') return [];
|
|
81
113
|
const out = [];
|
|
82
114
|
const data = event.data;
|
|
83
115
|
const record = typeof data === 'object' && data !== null ? data : undefined;
|
|
@@ -90,7 +122,7 @@ export function messageShapeViolations(event, loc) {
|
|
|
90
122
|
if (typeof message.id !== 'string' || message.id === '') {
|
|
91
123
|
out.push(violation('E6', loc, `${shape()}:id 必须为非空字符串`));
|
|
92
124
|
}
|
|
93
|
-
const expectedRole = type === 'assistant/message' ? 'assistant' : 'user';
|
|
125
|
+
const expectedRole = type === 'assistant/message' ? 'assistant' : (type === 'system/message' ? 'system' : 'user');
|
|
94
126
|
if (message.role !== expectedRole) {
|
|
95
127
|
out.push(violation('E6', loc, `${shape()}:role 必须为 "${expectedRole}",实际 ${String(message.role)}`));
|
|
96
128
|
}
|
|
@@ -106,6 +138,13 @@ export function messageShapeViolations(event, loc) {
|
|
|
106
138
|
out.push(violation('E6', loc, `${shape()}:assistant/message 必须带 model source(provider/model 非空)`));
|
|
107
139
|
}
|
|
108
140
|
}
|
|
141
|
+
if (type === 'system/message') {
|
|
142
|
+
// E9(R-G 候选规则):system/message 必须 plugin source(kind==='plugin' + plugin 非空)。
|
|
143
|
+
// 宿主依据:dsh-session@0.1.5-rc.1 lib/index.js:942-944("must have plugin source")。
|
|
144
|
+
if (source?.kind !== 'plugin' || typeof source.plugin !== 'string' || source.plugin === '') {
|
|
145
|
+
out.push(violation('E9', loc, `${shape()}:system/message 必须带 plugin source(kind==='plugin' 且 plugin 非空)——宿主 lib/index.js:942-944`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
109
148
|
if (type === 'tool/result') {
|
|
110
149
|
if (source?.kind !== 'tool' || typeof source.callId !== 'string' || source.callId === '') {
|
|
111
150
|
out.push(violation('E6', loc, `${shape()}:tool/result 必须带 tool source(callId 非空)`));
|
|
@@ -159,7 +198,7 @@ export function normalizeReplaceOp(op, version) {
|
|
|
159
198
|
*
|
|
160
199
|
* 按**文件物理行序**(非 seq 排序)要求展开后的事件 seq 严格单调递增。
|
|
161
200
|
* 单进程 append 不可能写出非单调物理序(appendCore 断言 seq==cursor+i 且按
|
|
162
|
-
* id 串行化)——非单调 =
|
|
201
|
+
* id 串行化)——非单调 = 多写入者/旧光标回放交织的现场特征(某真实会话:物理序
|
|
163
202
|
* 734056→733539→735470)。E2 只查「排序后连续」,排序会掩盖物理序倒退;
|
|
164
203
|
* S9 补「物理序单调」盲区。
|
|
165
204
|
*
|
|
@@ -202,7 +241,18 @@ export function replaySurface(events, version) {
|
|
|
202
241
|
|
|
203
242
|
if (!eligible) {
|
|
204
243
|
if (op !== undefined || src !== undefined) {
|
|
205
|
-
|
|
244
|
+
// ── R-B(2026-09-14 独立复核,误报 S2)────────────────────────────────
|
|
245
|
+
// 宿主 `@deepseek-ai/dsh-session@0.1.5-rc.1` 是**刻意容忍**的:
|
|
246
|
+
// lib/index.js:270 `if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable === true) return;`
|
|
247
|
+
// 同一函数的契约注释 lib/index.js:305 —— "Unknown ignorable records retain opaque
|
|
248
|
+
// metadata and never change the surface."
|
|
249
|
+
// ⇒ **未知**类型且 `ignorable===true` 的事件带 surfaceOp/sourceEventSeqs 是合法的不透明
|
|
250
|
+
// 元数据(宿主收;实测变异 06:宿主收、旧契约 S2/error)。这里不再报 S2。
|
|
251
|
+
// "该未知类型有没有消费者"由 E7 以 **warning** 表达(策略层,非宿主契约)。
|
|
252
|
+
const unknownIgnorable = !currentVocabulary(version).has(event.type) && event.ignorable === true;
|
|
253
|
+
if (!unknownIgnorable) {
|
|
254
|
+
violations.push(violation('S2', loc, `非 surface 类型 "${event.type}" 不得携带 surfaceOp/sourceEventSeqs`));
|
|
255
|
+
}
|
|
206
256
|
}
|
|
207
257
|
continue;
|
|
208
258
|
}
|
|
@@ -339,13 +389,13 @@ export function finalFold(events, version) {
|
|
|
339
389
|
}
|
|
340
390
|
|
|
341
391
|
/**
|
|
342
|
-
* T3 —— step 节点 key 冲突(2026-09-02 ·
|
|
392
|
+
* T3 —— step 节点 key 冲突(2026-09-02 · 渲染层白屏事故真正根因固化)。
|
|
343
393
|
*
|
|
344
394
|
* 客户端渲染消息列表 = 从事件流构建节点,节点 key = `data.turn:data.step`
|
|
345
395
|
* (React 列表 key)。同 turn 内两个 step/start 的 step 号相同 → key 冲突 →
|
|
346
|
-
* React 渲染死循环 →
|
|
347
|
-
*
|
|
348
|
-
*
|
|
396
|
+
* React 渲染死循环 → 白屏/不展示(实测:同一 turn 内正常轮的 step 95/1 与
|
|
397
|
+
* 编辑块的 step 95/1 冲突;2026-09-02 全量扫描又发现多个同型冲突的会话,
|
|
398
|
+
* 均整块重编号修复)。
|
|
349
399
|
*
|
|
350
400
|
* 判定:扫 step/start,`data.turn:data.step` 组合重复 = error。
|
|
351
401
|
* - 无任何 step/start 的日志跳过(与 T1 同款宽松);
|
|
@@ -367,7 +417,7 @@ export function stepKeyViolations(events) {
|
|
|
367
417
|
const key = `${String(turn)}:${String(step)}`
|
|
368
418
|
const prev = seen.get(key)
|
|
369
419
|
if (prev !== undefined) {
|
|
370
|
-
out.push(violation('T3', { seq: event.seq, lineNo, eventType: event.type }, `step 节点 key ${key} 冲突:seq ${prev.seq} 与 seq ${event.seq} 的 step/start 同 turn 同 step——客户端 React
|
|
420
|
+
out.push(violation('T3', { seq: event.seq, lineNo, eventType: event.type }, `step 节点 key ${key} 冲突:seq ${prev.seq} 与 seq ${event.seq} 的 step/start 同 turn 同 step——客户端 React 渲染死循环白屏(渲染层事故;修复:同 turn 内 step 递增,不得复用;整块重编号)`))
|
|
371
421
|
} else {
|
|
372
422
|
seen.set(key, { seq: event.seq, lineNo })
|
|
373
423
|
}
|
|
@@ -376,11 +426,11 @@ export function stepKeyViolations(events) {
|
|
|
376
426
|
}
|
|
377
427
|
|
|
378
428
|
/**
|
|
379
|
-
* T4 —— step/消息本体 turn 缺失(null/undefined)(2026-09-01 · D8
|
|
429
|
+
* T4 —— step/消息本体 turn 缺失(null/undefined)(2026-09-01 · D8 事故固化)。
|
|
380
430
|
*
|
|
381
431
|
* 客户端渲染状态机对 turn=null 的 step/消息**无法归属任何 turn** → 渲染死循环 →
|
|
382
|
-
*
|
|
383
|
-
* step/start+marker+step/end turn 全 null
|
|
432
|
+
* 白屏「载入历史」(实测:retrace 0.4.17 写的编辑块
|
|
433
|
+
* step/start+marker+step/end turn 全 null;离线自查脚本把 step
|
|
384
434
|
* 包裹/消息本体的 null-turn 判为致命)。
|
|
385
435
|
*
|
|
386
436
|
* 判定:`step/start|step/end|assistant/message` 的 `data.turn === null/undefined` = error。
|
|
@@ -397,20 +447,20 @@ export function nullTurnStepViolations(events) {
|
|
|
397
447
|
if (event.type !== 'step/start' && event.type !== 'step/end' && event.type !== 'assistant/message') continue
|
|
398
448
|
const turn = event.data?.turn
|
|
399
449
|
if (turn === null || turn === undefined) {
|
|
400
|
-
out.push(violation('T4', { seq: event.seq, lineNo, eventType: event.type }, `${event.type} 的 data.turn 为 ${String(turn)}(缺失)——客户端渲染状态机无法归属任何 turn → 渲染死循环白屏(D8
|
|
450
|
+
out.push(violation('T4', { seq: event.seq, lineNo, eventType: event.type }, `${event.type} 的 data.turn 为 ${String(turn)}(缺失)——客户端渲染状态机无法归属任何 turn → 渲染死循环白屏(D8 事故;step/消息本体必须带真实 turn 号)`))
|
|
401
451
|
}
|
|
402
452
|
}
|
|
403
453
|
return out
|
|
404
454
|
}
|
|
405
455
|
|
|
406
456
|
/**
|
|
407
|
-
* T5 —— turn/end 缺 reason.kind(2026-09-02 ·
|
|
457
|
+
* T5 —— turn/end 缺 reason.kind(2026-09-02 · malformed turn/end 固化)。
|
|
408
458
|
*
|
|
409
459
|
* 官方 agent-loop 写 turn/end 恒带 `reason: { kind }`(dsh-agent-loop/lib/index.js:620;
|
|
410
460
|
* kind ∈ completed|max-tokens|blocked|aborted|error|interrupted,中断恢复补
|
|
411
461
|
* interrupted,见 dsh-session interruptedTurnClosers)。官方 validation 强制
|
|
412
462
|
* reason.kind 存在——缺失 = malformed → 会话加载失败
|
|
413
|
-
* (SessionPersistenceCorruptionError "malformed pre-react-loop turn/end"
|
|
463
|
+
* (SessionPersistenceCorruptionError "malformed pre-react-loop turn/end")。
|
|
414
464
|
*
|
|
415
465
|
* 判定:turn/end 的 `data.reason?.kind` 缺失/非字符串 = error。
|
|
416
466
|
*
|
|
@@ -423,14 +473,14 @@ export function turnEndReasonViolations(events) {
|
|
|
423
473
|
if (event.type !== 'turn/end') continue
|
|
424
474
|
const reason = event.data?.reason
|
|
425
475
|
if (!reason || typeof reason.kind !== 'string') {
|
|
426
|
-
out.push(violation('T5', { seq: event.seq, lineNo, eventType: event.type }, `turn/end 缺 data.reason.kind(reason=${JSON.stringify(reason)})——官方 validation 拒绝 → 会话加载失败(
|
|
476
|
+
out.push(violation('T5', { seq: event.seq, lineNo, eventType: event.type }, `turn/end 缺 data.reason.kind(reason=${JSON.stringify(reason)})——官方 validation 拒绝 → 会话加载失败(malformed turn/end;turn/end 必须带 reason.kind,镜像 dsh-agent-loop:620)`))
|
|
427
477
|
}
|
|
428
478
|
}
|
|
429
479
|
return out
|
|
430
480
|
}
|
|
431
481
|
|
|
432
482
|
/**
|
|
433
|
-
* E7 —— ignorable 未知 type 合法性(2026-09-09
|
|
483
|
+
* E7 —— ignorable 未知 type 合法性(2026-09-09 T2 增量)。
|
|
434
484
|
*
|
|
435
485
|
* 盲区:E3 对"未知 type + ignorable:true"直接放行(容忍更新版本 harness 写入),
|
|
436
486
|
* 但 ignorable 标记无合法性校验 = 后门——被读路径接纳却无人消费的未知事件 =
|
|
@@ -459,7 +509,7 @@ export function ignorableTypeViolations(events, version) {
|
|
|
459
509
|
}
|
|
460
510
|
|
|
461
511
|
/**
|
|
462
|
-
* P3 —— tool/call ↔ tool/result
|
|
512
|
+
* P3 —— tool/call ↔ tool/result 配对完整性(考古 B1)。
|
|
463
513
|
* 每个 tool/call 的 `data.callId` 必须能在 tool/result 的
|
|
464
514
|
* `data.message.source.callId` 中找到配对;孤儿 call(无 result)告警——
|
|
465
515
|
* 中断/失败轮次可能产生孤儿(合法但要审计)。warning 级:不破坏日志。
|
|
@@ -467,7 +517,7 @@ export function ignorableTypeViolations(events, version) {
|
|
|
467
517
|
export function toolPairingViolations(events) {
|
|
468
518
|
const out = [];
|
|
469
519
|
const calls = new Map(); // callId → { command, loc }
|
|
470
|
-
const results = new Map(); // callId → loc(双向:孤儿 result 也要指认,2026-09-09
|
|
520
|
+
const results = new Map(); // callId → loc(双向:孤儿 result 也要指认,2026-09-09 T1)
|
|
471
521
|
for (const { event, lineNo } of events) {
|
|
472
522
|
if (event.type === 'tool/call') {
|
|
473
523
|
const callId = event.data?.callId;
|
|
@@ -489,7 +539,7 @@ export function toolPairingViolations(events) {
|
|
|
489
539
|
out.push(violation('P3', loc, `tool/call ${callId}(命令 ${command || '(未知)'})没有配对的 tool/result——孤儿调用(中断/失败未落结果),考古提取将缺该输出`));
|
|
490
540
|
}
|
|
491
541
|
}
|
|
492
|
-
//
|
|
542
|
+
// 双向(T1 增量):孤儿 result = result 无对应 tool/call。
|
|
493
543
|
// 折叠后 wire 流中无主 tool 消息 = provider 拒绝风险(W1/W2 同族、不同层);
|
|
494
544
|
// 与孤儿 call 同为 warning 级——合法场景(中断/修复产物)不破坏日志。
|
|
495
545
|
for (const [callId, loc] of results) {
|
|
@@ -529,7 +579,7 @@ function findUnparsableContent(node, path) {
|
|
|
529
579
|
}
|
|
530
580
|
|
|
531
581
|
/**
|
|
532
|
-
* P4 —— tool/result
|
|
582
|
+
* P4 —— tool/result 输出结构契约(考古 B2)。
|
|
533
583
|
* `data.message.content` 必须可递归解析(list[dict{type:text,text}] 或等价);
|
|
534
584
|
* 不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。warning 级。
|
|
535
585
|
*/
|
|
@@ -607,7 +657,7 @@ export function tokenMeterViolations(events) {
|
|
|
607
657
|
* (lib/index.js:645)。
|
|
608
658
|
*
|
|
609
659
|
* 事故现场:DSH resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
|
|
610
|
-
* chunk 全部引用进新 assistant/message
|
|
660
|
+
* chunk 全部引用进新 assistant/message(某真实会话的 chunk 源引用跨 step 7/8/9)→
|
|
611
661
|
* 离线 check(T1)全绿但实机 token-meter 崩溃 → 同样刷屏压垮 host。
|
|
612
662
|
*
|
|
613
663
|
* @param events - 行序事件流(`{event, lineNo}`)。
|
|
@@ -801,7 +851,7 @@ export function wireViolations(events, version) {
|
|
|
801
851
|
* `assertReleasedArtifactRelationships`)**不是**在原始 v0 事件上跑的——它由 **v1→v2**
|
|
802
852
|
* 以 `RELEASED_V2_RELATIONSHIP_EXTENSIONS` 调用在**变换后的 v1/v2 artifact** 上
|
|
803
853
|
* (`v1-to-v2/lib/index.js:104`),并带 `cut`(继承切点)处理。第五轮实测:在原始 v0 上照抄该
|
|
804
|
-
* 状态机会在**已 seed
|
|
854
|
+
* 状态机会在**已 seed 的会话**上狂报(样本(某真实会话):v0→v1 官方并不以该规则拒绝,
|
|
805
855
|
* 而原始 v0 上会报 19 条),属"规则文本对、应用对象错"。要忠实复现必须先把 v0→v1→v2 的
|
|
806
856
|
* 变换做出来 ⇒ 记未覆盖。
|
|
807
857
|
*
|