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/lib/contracts.js CHANGED
@@ -4,8 +4,8 @@
4
4
  * DSH 会话日志契约规则目录(spec)。
5
5
  *
6
6
  * 规则集来源:
7
- * - `dsh-scale-audit-疑点记录.md`(59 条契约发现 / F1–F7 / N1–N6 / R1–R3)
8
- * - `复盘-会话修复事故-20260825.md`(三层契约:持久化 / 客户端引擎 / 插件语义)
7
+ * - 早期契约审计发现(59 / F1–F7 / N1–N6 / R1–R3)
8
+ * - 会话修复事故复盘(三层契约:持久化 / 客户端引擎 / 插件语义)
9
9
  * - `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对(见每条 `source`)
10
10
  *
11
11
  * 每条规则只描述"契约是什么";具体判定逻辑在 `lib/validate.js`(离线体检)
@@ -38,6 +38,29 @@ export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
38
38
  * - Z zstd 帧结构
39
39
  * - G 迁移预检(migration gate,**独立维度**:官方迁移会不会拒;不进 ok/verdict)
40
40
  */
41
+ /**
42
+ * R-F/R-D(2026-09-14 独立复核)——**机读的漂移/未复核标注**。
43
+ *
44
+ * 复核结论:规则的 `source` 大量停在 `@deepseek-ai/dsh-session@0.1.0-rc.7` 的行号上,
45
+ * 且其中 4 条(T2/P1/P2/C1)的**判定前提**在真宿主 0.1.5 上已不成立——此前**没有任何机制
46
+ * 能发现**(`source` 只是人读字符串)。这里把它们变成机读字段,并由 `check` 抬头点名。
47
+ */
48
+ export const SOURCE_DRIFT = Object.freeze({
49
+ /** 出处停在 rc.7 行号、未在 0.1.5 上复核(复核报告 §1 已给清单)。 */
50
+ drifted: Object.freeze(['T2', 'P1', 'P2', 'C1', 'I1', 'G3', 'R2', 'R3', 'E5', 'E6']),
51
+ /**
52
+ * **判定前提在 0.1.5 上不成立/无法判定**(复核 §1):
53
+ * T2 token-meter 已改从 `event.data.stream` 重建(无 sourceEventSeqs/无 belongs to another step);
54
+ * P1/P2 载体已换成 `user/message + data.id`(无 data.editor);C1 前提"无会话级排他锁"被
55
+ * `session.lock` flock 租约证伪;T3/T4 半步需真机实验。⇒ 这些规则的结论**不可单独采信**。
56
+ */
57
+ premiseStale: Object.freeze(['T2', 'P1', 'P2', 'C1']),
58
+ /** 复核明确"无法判定"、需要真机实验的条目(列出来是为了不假装覆盖)。 */
59
+ undecidable: Object.freeze(['T3', 'T4']),
60
+ note: '出处/前提漂移是**静默失真**:R-D 行为探针(lib/host-probes.js)负责机器可判的那部分,'
61
+ + '其余在此显式列出,结论抬头必须带漂移清单与未复核清单。',
62
+ });
63
+
41
64
  export const CONTRACT_RULES = [
42
65
  // ── H · header ──────────────────────────────────────────────────────────
43
66
  {
@@ -68,6 +91,7 @@ export const CONTRACT_RULES = [
68
91
  },
69
92
  {
70
93
  id: 'R2',
94
+ sourceVerified: false,
71
95
  title: 'chunk 行必须满足精确信封形状',
72
96
  layer: LAYER.PERSISTENCE,
73
97
  severity: SEVERITY.ERROR,
@@ -76,6 +100,7 @@ export const CONTRACT_RULES = [
76
100
  },
77
101
  {
78
102
  id: 'R3',
103
+ sourceVerified: false,
79
104
  title: 'chunk 行展开后成员 seq/time 安全',
80
105
  layer: LAYER.PERSISTENCE,
81
106
  severity: SEVERITY.ERROR,
@@ -105,15 +130,16 @@ export const CONTRACT_RULES = [
105
130
  title: '文件物理序 seq 单调(多写入者交织现场特征)',
106
131
  layer: LAYER.PERSISTENCE,
107
132
  severity: SEVERITY.ERROR,
108
- source: '2026-08-28 实锤:526f1835 文件物理序 734056→733539→735470;单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出',
133
+ source: '2026-08-28 实锤(某真实会话):文件物理序出现回退(大→小→更大);单进程 appendCore 断言 seq==cursor+i 且按 id 串行化不可能写出',
109
134
  description: '按文件物理行序要求展开后事件 seq 严格单调递增。E2 在排序后检查(loadSessionLog 会 sort),物理序倒退被掩盖;S9 在排序前按行序检查,非单调 = 多写入者/旧光标回放交织的直接现场证据,加载会被拒。',
110
135
  },
111
136
  {
112
137
  id: 'I1',
138
+ sourceVerified: false,
113
139
  title: 'inbox seed 相对重放(fork 边界孤儿 spliced)',
114
140
  layer: LAYER.ENGINE,
115
141
  severity: SEVERITY.ERROR,
116
- source: '@deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤:62c5b531/73ed35d8 fork 边界 removedCount=1 孤儿',
142
+ source: '@deepseek-ai/dsh-agent lib/types/inbox.js:155-178 (apply/validate);2026-08-28 实锤(某两个真实会话):fork 边界 removedCount=1 孤儿',
117
143
  description: '从 header.seedLength 起重放 agent/inbox/spliced,next-turn/next-step 双队列;start+removedCount 不得超过队列长、不得产生重复 pending id。fork 时"移除父待处理提示词"的 splice 假设父会话 inbox,子会话 seed 相对空 inbox 上非法 → resume 被拒(invalid persisted inbox splice)。',
118
144
  },
119
145
  {
@@ -134,14 +160,16 @@ export const CONTRACT_RULES = [
134
160
  },
135
161
  {
136
162
  id: 'E5',
163
+ sourceVerified: false,
137
164
  title: '禁用遗留词汇',
138
165
  layer: LAYER.PERSISTENCE,
139
166
  severity: SEVERITY.ERROR,
140
167
  source: '@deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)',
141
- description: 'request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式,写入即被拒。',
168
+ description: '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),不是写入即被拒。',
142
169
  },
143
170
  {
144
171
  id: 'E6',
172
+ sourceVerified: false,
145
173
  title: '消息类事件消息形状',
146
174
  layer: LAYER.PERSISTENCE,
147
175
  severity: SEVERITY.ERROR,
@@ -225,50 +253,83 @@ export const CONTRACT_RULES = [
225
253
  },
226
254
  {
227
255
  id: 'T2',
256
+ premiseStale: true,
257
+ sourceVerified: false,
228
258
  title: 'token-meter 源引用:assistant/message 的 sourceEventSeqs 引用的 chunk 必须同 turn/step',
229
259
  layer: LAYER.ENGINE,
230
260
  severity: SEVERITY.ERROR,
231
261
  source: '@deepseek-ai/dsh-token-meter lib/index.js:634-650 (_estimateProviderAssistant,:645 belongs to another step)',
232
- description: 'token meter 重建 provider 输出时,逐条检查 assistant/message 的 sourceEventSeqs:指向 assistant/chunk 的引用必须与消息同 turn/step,且 seq 更早、不重复;跨 step 引用 → 官方抛 belongs to another step → 每次事件追加都重抛(consumedEvents 不前进)→ 刷屏压垮 host(2026-08-30 实测 526f1835 seq 936047 跨 step 7/8/9)。T1 只查 step 配对不查源引用,此条补盲区;修复用 fix --clip-crossstep。',
262
+ description: '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。',
233
263
  },
234
264
  {
235
265
  id: 'T3',
236
266
  title: 'step 节点 key 唯一(同 turn 内 step/start 的 step 号不得复用)',
237
267
  layer: LAYER.ENGINE,
238
268
  severity: SEVERITY.ERROR,
239
- source: '复盘 2026-09-02 1e99e1ff 白屏(修复线 session-3f9e4f12):客户端渲染节点 key = turn:step,冲突 → React 渲染死循环;工具 tools/check-step-keys.mjs',
240
- description: '客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(1e99e1ff:580034 step 95/1 vs 580037 编辑块 step 95/1;6924781d/97786207/4b149a4a 同型)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。',
269
+ source: '复盘 2026-09-02 渲染层白屏事故:客户端渲染节点 key = turn:step,冲突 → React 渲染死循环(由离线自查脚本判出)',
270
+ description: '客户端渲染消息列表从事件流构建节点,节点 key = data.turn:data.step。同 turn 内两个 step/start 的 step 号相同 → key 冲突 → React 渲染死循环 → 白屏/不展示(实测:同一 turn 内两个 step/start 复用 step 95/1,一个来自正常轮、一个来自编辑块;后续全量扫描又发现多个同型冲突)。修复:同 turn 内 step 递增、整块重编号(含块内 chunk/tool/assistant)。',
241
271
  },
242
272
  {
243
273
  id: 'T4',
244
274
  title: 'step/消息本体 turn 缺失(null/undefined)→ 渲染死循环',
245
275
  layer: LAYER.ENGINE,
246
276
  severity: SEVERITY.ERROR,
247
- source: '复盘 D8 1e99e1ff(2026-09-01):retrace 0.4.17 编辑块 turn:null;修复线 tools/check-null-turn.mjs 判致命',
248
- description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(1e99e1ff seq 580037-580039)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。',
277
+ source: '复盘 2026-09-01 D8 事故:retrace 0.4.17 编辑块 turn:null(离线自查脚本判致命)',
278
+ description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(实测:编辑块的 step/start + marker + step/end 连续若干行 turn 全为 null)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。',
249
279
  },
250
280
  {
251
281
  id: 'T5',
252
282
  title: 'turn/end 必须带 data.reason.kind',
253
283
  layer: LAYER.ENGINE,
254
284
  severity: SEVERITY.ERROR,
255
- source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});1f4d986e malformed turn/end 事故(2026-09-02,修复线 check-turn-end-reason.mjs)',
256
- description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。1f4d986e:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。',
285
+ source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});malformed turn/end 事故(2026-09-02,由离线自查脚本判出)',
286
+ description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。实测:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。',
257
287
  },
258
288
  {
259
289
  id: 'E7',
260
290
  title: 'ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)',
261
291
  layer: LAYER.PERSISTENCE,
262
292
  severity: SEVERITY.WARNING,
263
- source: '反向挑刺 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)',
293
+ source: '对抗性复查 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)',
264
294
  description: '未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。',
265
295
  },
296
+ {
297
+ id: 'E8',
298
+ title: '事件信封键白名单(多余键:seed/restore 路径会拒)',
299
+ layer: LAYER.PERSISTENCE,
300
+ // R-G 候选规则 + R-D 探针订正:宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1
301
+ // lib/index.js:849-861)确实拒多余键,但其**唯一调用点是 Session 构造器的 seed 路径**
302
+ // (:1063-1068);JSONL **load 路径**(`adoptSessionEvent`)实测**容忍**(探针 p5 钉住)。
303
+ // 故本规则按 **warning** 报(不影响"可加载",但该日志作为 seed/restore 输入会被拒)。
304
+ severity: SEVERITY.WARNING,
305
+ source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:849-861(assertSessionEventEnvelope)+ :1063-1068(唯一调用点=seed 路径);load 路径容忍见行为探针 p5',
306
+ description: '事件对象只允许 7 个信封键(type/seq/time/data/surfaceOp/sourceEventSeqs/ignorable)。独立复核变异 05 指出"宿主拒、旧契约 0 违规";R-D 行为探针进一步订正口径:**load 路径容忍、seed/restore 路径拒** ⇒ warning。',
307
+ candidate: true,
308
+ },
309
+ {
310
+ id: 'E9',
311
+ title: 'system/message 必须带 plugin source',
312
+ layer: LAYER.PERSISTENCE,
313
+ severity: SEVERITY.ERROR,
314
+ source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:942-944("must have plugin source");角色表 :917-926',
315
+ description: 'v3 新增的 system/message:role 必须为 system,source.kind 必须为 plugin 且 plugin 非空。实测(变异 03):source.kind=\'user\' 宿主拒、旧契约 0 违规 ⇒ 漏检。',
316
+ candidate: true,
317
+ },
318
+ {
319
+ id: 'E10',
320
+ title: 'request/header 的 data.header 字段约束',
321
+ layer: LAYER.PERSISTENCE,
322
+ severity: SEVERITY.ERROR,
323
+ source: '@deepseek-ai/dsh-session@0.1.5-rc.1 lib/index.js:231-248(validateSessionEventData:omit header.system / omit empty tools / omit empty adapterDefaults)',
324
+ description: 'request/header 必须省略 header.system(系统提示改走 system/message)、空 tools、空 adapterDefaults。实测(变异 12):带 header.system 的写入宿主拒、旧契约 0 违规 ⇒ 漏检。',
325
+ candidate: true,
326
+ },
266
327
  {
267
328
  id: 'Z3',
268
329
  title: '空会话文件(有 header 无事件)显式报出',
269
330
  layer: LAYER.FRAMING,
270
331
  severity: SEVERITY.WARNING,
271
- source: '反向挑刺 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)',
332
+ source: '对抗性复查 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)',
272
333
  description: '有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。',
273
334
  },
274
335
  {
@@ -276,7 +337,7 @@ export const CONTRACT_RULES = [
276
337
  title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
277
338
  layer: LAYER.PLUGIN,
278
339
  severity: SEVERITY.WARNING,
279
- source: 'dsh-会话日志考古-插件任务与方法.md §2/§4.2(callId 配对,不可用"上一个 call"推断)',
340
+ source: '考古方法 §2/§4.2(callId 配对,不可用"上一个 call"推断)',
280
341
  description: '每个 tool/call 的 data.callId 必须能在 tool/result 的 data.message.source.callId 中找到配对;孤儿 call(无 result)告警——中断/失败轮次可能产生孤儿(合法但要审计),考古提取将缺该输出。',
281
342
  },
282
343
  {
@@ -284,7 +345,7 @@ export const CONTRACT_RULES = [
284
345
  title: 'tool/result 输出结构可解析(考古 B2)',
285
346
  layer: LAYER.PLUGIN,
286
347
  severity: SEVERITY.WARNING,
287
- source: 'dsh-会话日志考古-插件任务与方法.md §2/§4.2(content 递归 text 结构)',
348
+ source: '考古方法 §2/§4.2(content 递归 text 结构)',
288
349
  description: 'tool/result 的 data.message.content 必须可递归解析(list[dict{type:text,text}] 或等价);不可解析片段 = 考古提取将漏数据。空 content(失败/无输出)合法。',
289
350
  },
290
351
  // ── M · 客户端引擎层 ────────────────────────────────────────────────────
@@ -300,6 +361,8 @@ export const CONTRACT_RULES = [
300
361
  // ── P · 插件 marker 语义层 ──────────────────────────────────────────────
301
362
  {
302
363
  id: 'P1',
364
+ premiseStale: true,
365
+ sourceVerified: false,
303
366
  title: 'marker id 前缀必须被识别',
304
367
  layer: LAYER.PLUGIN,
305
368
  severity: SEVERITY.WARNING,
@@ -308,6 +371,8 @@ export const CONTRACT_RULES = [
308
371
  },
309
372
  {
310
373
  id: 'P2',
374
+ premiseStale: true,
375
+ sourceVerified: false,
311
376
  title: 'marker 自身 seq 不得出现在自身 shadowed 集',
312
377
  layer: LAYER.PLUGIN,
313
378
  severity: SEVERITY.ERROR,
@@ -318,6 +383,8 @@ export const CONTRACT_RULES = [
318
383
  // ── C · 并发 / 写入者假设 ───────────────────────────────────────────────
319
384
  {
320
385
  id: 'C1',
386
+ premiseStale: true,
387
+ sourceVerified: false,
321
388
  title: 'seq 缺口/倒退提示多写入者',
322
389
  layer: LAYER.CONCURRENCY,
323
390
  severity: SEVERITY.WARNING,
@@ -384,6 +451,7 @@ export const CONTRACT_RULES = [
384
451
  },
385
452
  {
386
453
  id: 'G3',
454
+ sourceVerified: false,
387
455
  title: '迁移预检:v0 源 session/title 系列的 messageSeqs 必须引用更早的人类 user/message',
388
456
  layer: LAYER.MIGRATION,
389
457
  severity: SEVERITY.WARNING,
@@ -0,0 +1,147 @@
1
+ /**
2
+ * dsh-log-contract · lib/host-probes.js —— **行为探针**(漂移检测的正解)
3
+ * (2026-09-14 独立复核 §4 + 裁定 R-D)
4
+ *
5
+ * 为什么需要它(复核原话):本包"承重墙建错了地方"——**委托宿主运行时函数**的判据基本正确,
6
+ * **作者手写镜像/自建模型**的判据成片出错,而 `hostPackageVersion()`/`hostCapability()`
7
+ * **只打印不门禁**、规则的 `source` 是**不可机读的字符串** ⇒ 本次 5 类失准**没有任何机制能发现**。
8
+ *
9
+ * 本模块的回答:**不靠人重读源码,靠宿主运行时自身当 oracle**。做法是自造**微型合成日志**
10
+ * 喂给宿主导出的 `foldSurface` / `adoptSessionEvent`(可选 `Session.create`),断言"宿主应当
11
+ * 这么判",再把**本包规则的假设**与宿主实际行为逐条比对:
12
+ * - 一致 → 该规则组 **VERIFIED**(在**当前宿主**上);
13
+ * - 不一致 → 该规则组标 **UNVERIFIED**,结论抬头显式点名,并提示**勿据此跑 `fix --apply`**。
14
+ *
15
+ * 探针只做**只读调用**(不写盘、不联网、不改任何文件);任何异常都被收敛成"探针失败/跳过",
16
+ * 不让漂移检测把工具本身弄崩。
17
+ */
18
+ import {
19
+ adoptSessionEvent, foldSurface, isSurfaceEligibleType, KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION,
20
+ } from '@deepseek-ai/dsh-session';
21
+ import { hostPackageVersion } from './vocab.js';
22
+
23
+ /**
24
+ * 探针定义。每条:
25
+ * `rules` = 该探针**背书**的规则 id(不一致时这些规则被标 UNVERIFIED);
26
+ * `expect` = 我们对宿主行为的期望(同时就是规则的假设);
27
+ * `run` = 用宿主真代码判定实际行为(返回 true=与期望一致)。
28
+ * 说明:断言只看"宿主收/拒"这一层语义(可用 `adoptSessionEvent` 与 `foldSurface` 观察),
29
+ * 不复制本包任何判定逻辑——否则又变成"自己给自己打分"。
30
+ */
31
+ export const PROBES = [
32
+ {
33
+ id: 'p1-replace-v3-fields',
34
+ title: 'v3 replace 只认 {op,startSeq,endSeq}(旧 {start,end} 必须拒)',
35
+ rules: ['S1', 'S4'],
36
+ expect: 'v3 形状通过、rc.7 旧形状被拒',
37
+ run: () => {
38
+ const mk = (op) => ({ type: 'user/message', seq: 1, time: 1, surfaceOp: op, sourceEventSeqs: [0], data: { id: 'u1', role: 'user', source: { kind: 'user' }, content: [] } });
39
+ const base = [{ type: 'user/message', seq: 0, time: 0, surfaceOp: 'append', data: { id: 'u0', role: 'user', source: { kind: 'user' }, content: [] } }];
40
+ const modern = rejects(() => foldSurface([...base, mk({ op: 'replace', startSeq: 0, endSeq: 0 })]));
41
+ const legacy = rejects(() => foldSurface([...base, mk({ op: 'replace', start: 0, end: 0 })]));
42
+ return modern === false && legacy === true;
43
+ },
44
+ },
45
+ {
46
+ id: 'p2-assistant-message-provenance',
47
+ title: 'assistant/message 不得携带 sourceEventSeqs(v3 起)',
48
+ rules: ['S6', 'E4', 'S8'],
49
+ expect: '带 provenance 的 assistant/message 被拒',
50
+ run: () => rejects(() => adoptSessionEvent({
51
+ type: 'assistant/message', seq: 1, time: 1, surfaceOp: 'append', sourceEventSeqs: [0],
52
+ data: { turn: 0, step: 1, message: { id: 'a1', role: 'assistant', source: { kind: 'model', provider: 'p', model: 'm' }, content: [] } },
53
+ })) === true,
54
+ },
55
+ {
56
+ id: 'p3-system-message-plugin-source',
57
+ title: 'system/message 是 surface 类型且必须 plugin source(kind=user 必须拒)',
58
+ rules: ['E6', 'E9'],
59
+ expect: 'plugin source 通过;user source 被拒',
60
+ run: () => {
61
+ const eligible = isSurfaceEligibleType('system/message') === true;
62
+ const withPlugin = rejects(() => adoptSessionEvent({
63
+ type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
64
+ data: { turn: 0, step: 0, message: { id: 's0', role: 'system', source: { kind: 'plugin', plugin: 'p' }, content: [] } },
65
+ }));
66
+ const withUser = rejects(() => adoptSessionEvent({
67
+ type: 'system/message', seq: 0, time: 1, surfaceOp: 'append',
68
+ data: { turn: 0, step: 0, message: { id: 's0', role: 'system', source: { kind: 'user' }, content: [] } },
69
+ }));
70
+ return eligible && withPlugin === false && withUser === true;
71
+ },
72
+ },
73
+ {
74
+ id: 'p4-unknown-ignorable-opaque-metadata',
75
+ title: '未知 + ignorable 的 opaque 元数据被刻意容忍(S2 不得报 error)',
76
+ rules: ['S2'],
77
+ expect: '未知 ignorable + surfaceOp 被接受',
78
+ run: () => rejects(() => adoptSessionEvent({
79
+ type: 'future/thing', seq: 0, time: 1, ignorable: true, surfaceOp: 'append', data: { k: 1 },
80
+ })) === false,
81
+ },
82
+ {
83
+ id: 'p5-envelope-extra-keys-load-tolerant',
84
+ title: '信封多余键:load 路径(adoptSessionEvent)**容忍**,只在 seed 路径拒(E8 的口径)',
85
+ rules: ['E8'],
86
+ expect: 'adoptSessionEvent 不拒(宿主 load 路径容忍)',
87
+ // 这条探针**修正了一条规则的过度声称**(R-D 的价值实证):
88
+ // · 宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1 lib/index.js:849-861)确实拒多余键,
89
+ // 但它的**唯一调用点**是 Session 构造器的 **seed 路径**(:1063-1068)——不是 JSONL load 路径;
90
+ // · load 路径(`adoptSessionEvent`)实测**容忍**多余键。
91
+ // ⇒ E8 从 error 降为 **warning**、口径限定为"seed/restore 会拒"(原判"写入即拒"是过度声称)。
92
+ run: () => rejects(() => adoptSessionEvent({
93
+ type: 'user/message', seq: 0, time: 1, surfaceOp: 'append', extra: 1,
94
+ data: { id: 'u0', role: 'user', source: { kind: 'user' }, content: [] },
95
+ })) === false,
96
+ },
97
+ {
98
+ id: 'p6-request-header-system',
99
+ title: 'request/header 必须省略 header.system(E10)',
100
+ rules: ['E10'],
101
+ expect: '带 header.system 的 request/header 被拒',
102
+ run: () => rejects(() => adoptSessionEvent({
103
+ type: 'request/header', seq: 0, time: 1, surfaceOp: 'append', data: { header: { system: 'x' } },
104
+ })) === true,
105
+ },
106
+ ];
107
+
108
+ /** 宿主是否 **拒** 这次调用(true = 抛错/拒绝)。 */
109
+
110
+ function rejects(fn) {
111
+ try {
112
+ fn();
113
+ return false;
114
+ } catch {
115
+ return true;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * 跑全部探针。
121
+ * @returns {{hostPackage:string, sessionFormatVersion:number, knownTypes:number,
122
+ * verified:boolean, probes:Array<{id,title,rules,expect,ok,error?}>, unverifiedRules:string[]}}
123
+ */
124
+ export function runHostProbes(probes = PROBES) {
125
+ const results = [];
126
+ const unverified = new Set();
127
+ for (const probe of probes) {
128
+ let ok = false;
129
+ let error;
130
+ try {
131
+ ok = probe.run() === true;
132
+ } catch (err) {
133
+ ok = false;
134
+ error = String(err?.message ?? err).slice(0, 200);
135
+ }
136
+ results.push({ id: probe.id, title: probe.title, rules: probe.rules, expect: probe.expect, ok, ...(error ? { error } : {}) });
137
+ if (!ok) for (const r of probe.rules) unverified.add(r);
138
+ }
139
+ return {
140
+ hostPackage: hostPackageVersion(),
141
+ sessionFormatVersion: SESSION_FORMAT_VERSION,
142
+ knownTypes: KNOWN_SESSION_EVENT_TYPES.size,
143
+ verified: unverified.size === 0,
144
+ probes: results,
145
+ unverifiedRules: [...unverified].sort(),
146
+ };
147
+ }
package/lib/index.js CHANGED
@@ -10,5 +10,6 @@ export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
10
  export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, trimLastMessagesByBudget, estimateTokensText, compactLastMessagesText, rebuildZstdText, tailRenumberText, neutralizeOrphanText, extractTurnText, keepRangesText } from './repair.js';
11
11
  export { CONTRACT_RULES, LAYER, SEVERITY, ruleById } from './contracts.js';
12
12
  export { HOST_MAX_FILE_VERSION, hostPackageVersion, hostCapability, isAssessableFileVersion } from './vocab.js';
13
+ export { TESTED_BASELINE, compareVersions, detectSupport, supportOfLog } from './version-support.js';
13
14
  export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, ignorableTypeViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
14
15
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
package/lib/log-reader.js CHANGED
@@ -76,9 +76,21 @@ export function scanZstdFrames(buf) {
76
76
  }
77
77
 
78
78
  /** 把 zstd 多帧拼成完整明文;任一帧解码失败即抛错(N5 单帧全损语义)。 */
79
- export function decompressZstd(buf) {
79
+ export function decompressZstd(buf, { allowTorn = true } = {}) {
80
80
  const { frames, torn } = scanZstdFrames(buf);
81
- if (torn) throw new Error('zstd 尾帧撕裂(incomplete tail frame)——日志可能正在写入或已截断');
81
+ // ── R-B(2026-09-14 独立复核,误报 Z2)────────────────────────────────────
82
+ // 撕裂尾帧**不是**损坏:宿主 `dsh-session-persistence-jsonl@0.1.5-rc.1`
83
+ // · `readZstdPrefix`(lib/index.js:2791-2849)——"Decode complete frames and retain
84
+ // complete JSONL records from a torn final frame",恢复并返回 `tornTruncateTo`(:2847);
85
+ // · 下次写入 `:226-228` 调 `truncateTornTail` 自动截断。
86
+ // 旧实现(本函数)对 torn 直接抛错 ⇒ `loadSessionLog` 返回 header=null/events=[]
87
+ // ⇒ `validate.js` 级联出 Z1(warning)+Z2(error)+H1(error)、verdict=broken —— 把宿主
88
+ // **本来能修**的"边写边读"健康态报成"会话坏了"。现在对齐宿主:解出全部**完整帧**,
89
+ // 尾帧未写完的部分丢弃,由调用方用 `frameInfo.torn` 报 Z1(warning)。
90
+ // `frames.length === 0`(连一个完整帧都没有,例如头帧就断了)**仍然报错** —— 宿主
91
+ // `readZstdPrefix:2793` 对这种情况同样抛 "empty or header-less Zstandard session log"。
92
+ if (frames.length === 0) throw new Error('空文件 / 无完整 zstd 帧(incomplete or header-less session log)');
93
+ if (torn && !allowTorn) throw new Error('zstd 尾帧撕裂(incomplete tail frame)——strict 模式(allowTorn:false)');
82
94
  // 逐帧解码后一次性 Buffer.concat:避免每帧一次 concat 的 O(n²) 拷贝
83
95
  const parts = new Array(frames.length);
84
96
  for (let i = 0; i < frames.length; i++) {
@@ -86,6 +98,7 @@ export function decompressZstd(buf) {
86
98
  try {
87
99
  parts[i] = zstdDecompressSync(buf.subarray(s, e));
88
100
  } catch (err) {
101
+ // 完整帧解码失败才是真损坏(Z2):磁盘 bitrot / 传输截断 / 保留位非法
89
102
  throw new Error(`zstd 帧解码失败 [${s},${e}):${err.message}`);
90
103
  }
91
104
  }
@@ -230,8 +243,8 @@ export function loadSessionLog(path) {
230
243
  *
231
244
  * 帧布局:帧 1 = header 行(单行 JSON,~几百字节),帧 2+ = 事件流。
232
245
  * 短码推导(工作区 createdAt 序号)需要扫全部会话但只需要 header——
233
- * 读文件前缀 64KiB 足够覆盖完整帧 1,避免全量读大文件(opena 工作区
234
- * ~108MiB 压缩)。失败返回 null(调用方降级)。
246
+ * 读文件前缀 64KiB 足够覆盖完整帧 1,避免全量读大文件(大会话压缩后可达
247
+ * 数百 MiB)。失败返回 null(调用方降级)。
235
248
  *
236
249
  * @param {string} path 会话日志文件路径(.jsonl.zstd 或明文 .jsonl)。
237
250
  * @returns {object|null} header 对象;无法解析返回 null。
package/lib/prewrite.js CHANGED
@@ -18,12 +18,41 @@
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
20
  import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation, wireViolations } from './checks.js';
21
- import { resolveFormatVersion } from './vocab.js';
21
+ import { resolveFormatVersion, LEGACY_FORMAT_MAX_VERSION } from './vocab.js';
22
22
  import { normalizeEventSeqRanges } from './compat.js';
23
23
 
24
- /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
25
- function isKnownMarkerCandidate(event) {
26
- return Boolean(event) && event.type === 'assistant/message' && event.surfaceOp && event.surfaceOp !== 'append' && event.data?.editor !== undefined;
24
+ /**
25
+ * retrace 历史 marker 的 id 前缀(旧载体:`assistant/message` replace + `data.editor`)。
26
+ * retrace `MARKER_ID_PREFIX`(`retrace`/`message-editor`——后者是插件改名前的旧前缀)对齐。
27
+ */
28
+ const LEGACY_MARKER_ID_PREFIXES = ['retrace', 'message-editor'];
29
+
30
+ /**
31
+ * **可识别的历史 retrace 载体**(R-C,2026-09-14 独立复核:白名单收窄)。
32
+ *
33
+ * 旧实现只判"任意 `data.editor` 存在" ⇒ **任何**写 `assistant/message` replace + `data.editor`
34
+ * 的第三方插件都能领 T1 豁免(复核判定:"设计粗糙的豁口")。收窄为四条同时成立:
35
+ * ① 类型/操作:`assistant/message` + replace;
36
+ * ② 载体标记:`data.editor !== undefined`;
37
+ * ③ **身份**:`data.message.id` 带 retrace 历史 marker 前缀(`retrace-*` / `message-editor-*`);
38
+ * ④ **形状**:`editor.targetSeq === (op.start ?? op.startSeq)`(retrace 自己的写前断言钉住的形状)。
39
+ * 任一不满足 → 不是可识别的 retrace 历史 marker,不降级(保持 error)。
40
+ *
41
+ * @returns {{prefix:string,id:string,targetSeq:number,seq:unknown}|null}
42
+ */
43
+ export function legacyMarkerKindOf(event) {
44
+ if (!event || event.type !== 'assistant/message') return null;
45
+ const op = event.surfaceOp;
46
+ if (!op || typeof op !== 'object' || op.op !== 'replace') return null;
47
+ const editor = event.data?.editor;
48
+ if (editor === undefined || editor === null || typeof editor !== 'object') return null;
49
+ const id = event.data?.message?.id;
50
+ if (typeof id !== 'string') return null;
51
+ const prefix = LEGACY_MARKER_ID_PREFIXES.find((p) => id.startsWith(`${p}-`));
52
+ if (!prefix) return null;
53
+ const start = op.start ?? op.startSeq;
54
+ if (editor.targetSeq !== start) return null;
55
+ return { prefix, id, targetSeq: editor.targetSeq, seq: event.seq };
27
56
  }
28
57
 
29
58
  /** 把一个"拟写事件"规整为带 seq 的事件;seq 未携带时按追加位置赋值。 */
@@ -106,15 +135,31 @@ export function createPreWriter(input = {}) {
106
135
  // assistant/message 配对失败保持 error。历史已有事件的 T1 归属离线体检
107
136
  // (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
108
137
  const lastCandidate = candidateEvents[candidateEvents.length - 1];
138
+ const legacyKind = legacyMarkerKindOf(lastCandidate);
139
+ // R-C 版本门(2026-09-14 独立复核):降级**只对 ≤v2 文件**。
140
+ // 依据:新载体(`user/message` + `data.id`,retrace 0.4.26)根本不带 `data.editor`,在 v3 上
141
+ // 降级**救不回任何写入**(v3 禁 assistant/message 带 provenance ⇒ S8 兜住) —— 留在 v3 上
142
+ // 只会掩盖 T1 的真实原因。v0/v1/v2 才是有价值的作用域(回放/重写历史形态 marker)。
143
+ const legacyDowngradeAllowed = formatVersion <= LEGACY_FORMAT_MAX_VERSION;
109
144
  for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
110
145
  if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
111
- if (isKnownMarkerCandidate(lastCandidate)) {
112
- violations.push({ ...t1, severity: 'warning', message: `${t1.message}(已知 retrace marker 设计债:压缩前需 doctor 清理)` });
146
+ if (legacyKind && legacyDowngradeAllowed) {
147
+ // 降级**可见**(R-C):违规里带 markerKind/id/targetSeq,并由结果字段 `legacyMarkerDebt`
148
+ // 显式带出;入口(CLI)打印"压缩前需一次性清理",不再"记了没人看"。
149
+ violations.push({
150
+ ...t1,
151
+ severity: 'warning',
152
+ markerKind: legacyKind.prefix,
153
+ markerId: legacyKind.id,
154
+ targetSeq: legacyKind.targetSeq,
155
+ message: `${t1.message}(已知历史 retrace marker 设计债:${legacyKind.prefix} 载体 id=${legacyKind.id} targetSeq=${legacyKind.targetSeq};`
156
+ + `仅对 v≤${LEGACY_FORMAT_MAX_VERSION} 文件降级。压缩前需一次性清理:fix --neutralize-legacy-markers)`,
157
+ });
113
158
  } else {
114
159
  violations.push(t1);
115
160
  }
116
161
  }
117
- // T3/T4 —— 渲染层(2026-09-02 1e99e1ff 白屏)。**error 级拒绝**:
162
+ // T3/T4 —— 渲染层(2026-09-02 渲染层白屏事故)。**error 级拒绝**:
118
163
  // - T4:拟写事件(step/start|step/end|assistant/message)turn 缺失 → 客户端
119
164
  // 渲染死循环白屏(D8),写入前直接拦下(防再犯:任何写 turn:null 的 marker);
120
165
  // - T3:拟写事件引入 step 节点 key 冲突(同 turn 同 step 的 step/start 重复)→
@@ -127,17 +172,23 @@ export function createPreWriter(input = {}) {
127
172
  if (v.seq !== lastCandidate?.seq) continue;
128
173
  violations.push(v);
129
174
  }
130
- // T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(1f4d986e 防再犯)
175
+ // T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(malformed turn/end 防再犯)
131
176
  for (const v of turnEndReasonViolations(candidateEvents.map((event) => ({ event })))) {
132
177
  if (v.seq !== lastCandidate?.seq) continue;
133
178
  violations.push(v);
134
179
  }
135
180
  const bySeverity = { error: 0, warning: 0, info: 0 };
136
181
  for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
182
+ // R-C「让降级可见」:把"本次写入沿用了历史 marker 形态(债)"作为**结构化字段**带出,
183
+ // 入口据此打印"压缩前需一次性清理"。降级不再只是 violations 里的一句 warning。
184
+ const legacyDebt = legacyKind && legacyDowngradeAllowed && bySeverity.error === 0
185
+ ? { kind: legacyKind.prefix, id: legacyKind.id, targetSeq: legacyKind.targetSeq, seq: lastCandidate?.seq ?? null, formatVersion }
186
+ : null;
137
187
  return {
138
188
  ok: bySeverity.error === 0,
139
189
  violations,
140
190
  bySeverity,
191
+ legacyMarkerDebt: legacyDebt,
141
192
  surface: folded.surface ?? { nodes: replay.nodes, replacements: [] },
142
193
  nextSeq: candidateEvents.length ? candidateEvents[candidateEvents.length - 1].seq + 1 : baseSeq,
143
194
  };
package/lib/repair.js CHANGED
@@ -123,8 +123,8 @@ export function strictScanText(text) {
123
123
  * 裁剪 assistant/message 的跨 step sourceEventSeqs(2026-08-30 第二类事故)。
124
124
  *
125
125
  * 现象:DSH 的 resend/regenerate 在 agent 仍开着 step 时被触发,会把旧 step 的
126
- * assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(526f1835
127
- * seq 936047:sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
126
+ * assistant/chunk 全部引用进新 assistant/message 的 sourceEventSeqs(实测:
127
+ * sourceEventSeqs 覆盖 turn 54 的 step 7/8/9 三段)。token-meter
128
128
  * 要求每个 source chunk 与消息同 turn/step(dsh-token-meter lib/index.js:645,
129
129
  * `belongs to another step`)→ 同样刷屏压垮 host。
130
130
  *
@@ -191,7 +191,7 @@ export function clipCrossStepSourcesText(text) {
191
191
  * @param {string} text - JSONL 全文(含 header 行)。
192
192
  * @returns {{ text: string, neutralized: number, seqs: Array<number> }}
193
193
  */
194
- export function neutralizeMarkersText(text) {
194
+ export function neutralizeMarkersText(text, { onlyLegacy = false } = {}) {
195
195
  const parts = text.split('\n');
196
196
  const seqs = [];
197
197
  let neutralized = 0;
@@ -209,6 +209,11 @@ export function neutralizeMarkersText(text) {
209
209
  if (v.data?.turn != null || v.data?.step != null) continue;
210
210
  const id = v.data?.message?.id;
211
211
  if (typeof id !== 'string' || !MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) continue;
212
+ // R-C 一次性根治路径(`--neutralize-legacy-markers`):只动**历史载体**
213
+ // (`assistant/message` + `data.editor`);新载体是 `user/message` + `data.id`,
214
+ // 本来就不在这个分支里(类型不符),这里额外的判据是"必须有 editor",
215
+ // 便于把"清历史债"与"泛化中和"区分开、也让报告口径可核对。
216
+ if (onlyLegacy && v.data?.editor === undefined) continue;
212
217
  const seq = v.seq;
213
218
  delete v.surfaceOp;
214
219
  delete v.sourceEventSeqs;
@@ -1153,7 +1158,10 @@ export function repairSession(file, opts = {}) {
1153
1158
  let plain;
1154
1159
  if (isZstd) {
1155
1160
  try {
1156
- plain = decompressZstd(buf).toString('utf8');
1161
+ // R-B 边界:**写路径不吃撕裂尾帧**。`check`(只读)按宿主语义恢复撕裂尾帧;但
1162
+ // `fix` 会回写文件,若尾帧是**活动会话正在写入**的部分,回写会把它截掉 ——
1163
+ // 与宿主"由持有租约的会话自己截断"(:226-228)不同责。故这里显式 strict。
1164
+ plain = decompressZstd(buf, { allowTorn: false }).toString('utf8');
1157
1165
  } catch (err) {
1158
1166
  return { file, ok: false, issues: [{ kind: 'zstd-decode', detail: String(err.message ?? err) }], removed: 0, renumbered: 0, applied: false };
1159
1167
  }
@@ -1194,7 +1202,16 @@ export function repairSession(file, opts = {}) {
1194
1202
  applyFix('markers', r, `移除 ${r.removed} 个 retrace/message-editor marker(重编号 ${r.renumbered} 行)`);
1195
1203
  }
1196
1204
  let neutralized = 0;
1197
- const neutralizedSeqs = [];
1205
+ let neutralizedSeqs = [];
1206
+ if (opts.neutralizeLegacyMarkers) {
1207
+ const r = neutralizeMarkersText(plain, { onlyLegacy: true });
1208
+ if (r.neutralized > 0) {
1209
+ plain = r.text;
1210
+ issues.push({ kind: 'neutralize-legacy-markers', detail: `一次性中和 ${r.neutralized} 个**历史载体** retrace marker(assistant/message + data.editor;type→retrace/marker + ignorable:true,删除 surfaceOp/sourceEventSeqs,seq/行数不变)→ 历史 token-meter 配对债根治(此后写前校验不再需要那条 ≤v2 降级白名单)` });
1211
+ }
1212
+ neutralized = r.neutralized;
1213
+ neutralizedSeqs = r.seqs;
1214
+ }
1198
1215
  if (opts.neutralize) {
1199
1216
  const r = neutralizeMarkersText(plain);
1200
1217
  neutralized = r.neutralized;
@@ -1228,10 +1245,10 @@ export function repairSession(file, opts = {}) {
1228
1245
  issues.push({ kind: 'trim-budget', detail: `估算 ${r.estimatedTokens} tokens ≤ 预算 ${opts.trimBudget}——无需裁剪(${r.kept} 条消息全保留)` });
1229
1246
  }
1230
1247
  }
1231
- // L4 新原语(2026-08-30 任务书 §L4 收编 tools/ 验证工具)
1248
+ // L4 新原语(2026-08-30 收编外部验证工具)
1232
1249
  if (typeof opts.tailRenumberDelta === 'number') {
1233
1250
  // 起点自动推导:从首个可平移的 seq 起(即所有事件都平移)。
1234
- // fix-tail 原工具是 <startLine> <delta> 双参;任务书 §L4 简化为单参 delta
1251
+ // fix-tail 原工具是 <startLine> <delta> 双参;L4 简化为单参 delta
1235
1252
  // (尾部全部平移)。若需部分平移,传 --tail-renumber 前先 --keep-ranges。
1236
1253
  const r = tailRenumberText(plain, 0, opts.tailRenumberDelta);
1237
1254
  if (r.error) {