dsh-log-contract 0.3.13 → 0.3.14

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/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
- if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result') return [];
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 串行化)——非单调 = 多写入者/旧光标回放交织的现场特征(526f1835 物理序
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
- violations.push(violation('S2', loc, `非 surface 类型 "${event.type}" 不得携带 surfaceOp/sourceEventSeqs`));
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 · 1e99e1ff 白屏真正根因固化)。
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 渲染死循环 → 白屏/不展示(1e99e1ff:seq 580034 step 95/1 vs 580037
347
- * 编辑块 step 95/1;修复线 2026-09-02 全量扫描另发现 6924781d / 97786207 /
348
- * 4b149a4a 同型冲突,均整块重编号修复)。
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 渲染死循环白屏(1e99e1ff 事故;修复:同 turn 内 step 递增,不得复用;整块重编号)`))
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 1e99e1ff 固化)。
429
+ * T4 —— step/消息本体 turn 缺失(null/undefined)(2026-09-01 · D8 事故固化)。
380
430
  *
381
431
  * 客户端渲染状态机对 turn=null 的 step/消息**无法归属任何 turn** → 渲染死循环 →
382
- * 白屏「载入历史」(1e99e1ff seq 580037-580039:retrace 0.4.17 写的编辑块
383
- * step/start+marker+step/end turn 全 null;修复线 check-null-turn.mjs 把 step
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 1e99e1ff;step/消息本体必须带真实 turn 号)`))
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 · 1f4d986e malformed turn/end 固化)。
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",1f4d986e)。
463
+ * (SessionPersistenceCorruptionError "malformed pre-react-loop turn/end")。
414
464
  *
415
465
  * 判定:turn/end 的 `data.reason?.kind` 缺失/非字符串 = error。
416
466
  *
@@ -423,7 +473,7 @@ 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 拒绝 → 会话加载失败(1f4d986e malformed turn/end;turn/end 必须带 reason.kind,镜像 dsh-agent-loop:620)`))
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
@@ -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(526f1835 seq 936047 step 7/8/9)→
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 的会话**上狂报(样本 `session-62c5b531`:v0→v1 官方并不以该规则拒绝,
854
+ * 状态机会在**已 seed 的会话**上狂报(样本(某真实会话):v0→v1 官方并不以该规则拒绝,
805
855
  * 而原始 v0 上会报 19 条),属"规则文本对、应用对象错"。要忠实复现必须先把 v0→v1→v2 的
806
856
  * 变换做出来 ⇒ 记未覆盖。
807
857
  *
package/lib/contracts.js CHANGED
@@ -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,35 +253,37 @@ 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',
@@ -263,6 +293,37 @@ export const CONTRACT_RULES = [
263
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 无事件)显式报出',
@@ -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
  }