dsh-log-contract 0.3.15 → 0.3.17
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 +47 -35
- package/README.zh.md +30 -28
- package/bin/dsh-log-contract.mjs +15 -15
- package/docs/CONTRACTS.md +31 -17
- package/lib/checks.js +14 -14
- package/lib/contracts.js +38 -23
- package/lib/host-probes.js +3 -3
- package/lib/log-reader.js +16 -5
- package/lib/prewrite.js +8 -8
- package/lib/repair.js +11 -13
- package/lib/validate.js +21 -11
- package/lib/version-support.js +1 -1
- package/lib/vocab.js +4 -4
- package/package.json +1 -1
package/lib/checks.js
CHANGED
|
@@ -70,10 +70,10 @@ export function envelopeViolations(event, loc, version) {
|
|
|
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
|
-
// ──
|
|
73
|
+
// ── 2026-09-14 实测:3 例确证漏检补成候选规则 ───────────────────────
|
|
74
74
|
// E8 信封键白名单:宿主 `assertSessionEventEnvelope`(@deepseek-ai/dsh-session@0.1.5-rc.1
|
|
75
75
|
// lib/index.js:852-861)逐键 switch,只认 7 个键,其余一律 `invalid event envelope`。
|
|
76
|
-
//
|
|
76
|
+
// 实测反例:宿主拒、旧契约 0 违规。
|
|
77
77
|
if (typeof event === 'object' && event !== null) {
|
|
78
78
|
const extra = Object.keys(event).filter((k) => !ENVELOPE_ALLOWED_KEYS.has(k));
|
|
79
79
|
if (extra.length > 0) {
|
|
@@ -82,7 +82,7 @@ export function envelopeViolations(event, loc, version) {
|
|
|
82
82
|
}
|
|
83
83
|
// E10 request/header 的 data 字段约束:宿主 `validateSessionEventData`
|
|
84
84
|
// (dsh-session@0.1.5-rc.1 lib/index.js:231-248)——`header.system` 必须省略、
|
|
85
|
-
// 空 tools / 空 adapterDefaults
|
|
85
|
+
// 空 tools / 空 adapterDefaults 必须省略。实测反例:宿主拒、旧契约 0 违规。
|
|
86
86
|
if (event.type === 'request/header') {
|
|
87
87
|
const header = event.data?.header;
|
|
88
88
|
if (typeof header !== 'object' || header === null || Array.isArray(header)) {
|
|
@@ -106,9 +106,9 @@ export const ENVELOPE_ALLOWED_KEYS = new Set(['type', 'seq', 'time', 'data', 'su
|
|
|
106
106
|
/** 镜像官方 assertMessageEventShape(lib/index.js:1242-1266)。 */
|
|
107
107
|
export function messageShapeViolations(event, loc) {
|
|
108
108
|
const type = event.type;
|
|
109
|
-
//
|
|
109
|
+
// 把 v3 新增的 `system/message` 纳入形状检查(MESSAGE_ROLE_BY_TYPE:system/message → system,
|
|
110
110
|
// dsh-session@0.1.5-rc.1 lib/index.js:917-926;source 要求 :942-944)。旧实现只查 user/assistant/tool
|
|
111
|
-
// ⇒
|
|
111
|
+
// ⇒ 实测反例(system/message 的 source.kind='user')宿主拒、旧契约 0 违规。
|
|
112
112
|
if (type !== 'user/message' && type !== 'assistant/message' && type !== 'tool/result' && type !== 'system/message') return [];
|
|
113
113
|
const out = [];
|
|
114
114
|
const data = event.data;
|
|
@@ -139,7 +139,7 @@ export function messageShapeViolations(event, loc) {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
if (type === 'system/message') {
|
|
142
|
-
// E9(
|
|
142
|
+
// E9(候选规则):system/message 必须 plugin source(kind==='plugin' + plugin 非空)。
|
|
143
143
|
// 宿主依据:dsh-session@0.1.5-rc.1 lib/index.js:942-944("must have plugin source")。
|
|
144
144
|
if (source?.kind !== 'plugin' || typeof source.plugin !== 'string' || source.plugin === '') {
|
|
145
145
|
out.push(violation('E9', loc, `${shape()}:system/message 必须带 plugin source(kind==='plugin' 且 plugin 非空)——宿主 lib/index.js:942-944`));
|
|
@@ -241,13 +241,13 @@ export function replaySurface(events, version) {
|
|
|
241
241
|
|
|
242
242
|
if (!eligible) {
|
|
243
243
|
if (op !== undefined || src !== undefined) {
|
|
244
|
-
// ──
|
|
244
|
+
// ── 2026-09-14 实测:这里的 S2 是误报 ───────────────────────────────
|
|
245
245
|
// 宿主 `@deepseek-ai/dsh-session@0.1.5-rc.1` 是**刻意容忍**的:
|
|
246
246
|
// lib/index.js:270 `if (!KNOWN_SESSION_EVENT_TYPES.has(event.type) && event.ignorable === true) return;`
|
|
247
247
|
// 同一函数的契约注释 lib/index.js:305 —— "Unknown ignorable records retain opaque
|
|
248
248
|
// metadata and never change the surface."
|
|
249
249
|
// ⇒ **未知**类型且 `ignorable===true` 的事件带 surfaceOp/sourceEventSeqs 是合法的不透明
|
|
250
|
-
// 元数据(
|
|
250
|
+
// 元数据(宿主收;实测反例:宿主收、旧契约 S2/error)。这里不再报 S2。
|
|
251
251
|
// "该未知类型有没有消费者"由 E7 以 **warning** 表达(策略层,非宿主契约)。
|
|
252
252
|
const unknownIgnorable = !currentVocabulary(version).has(event.type) && event.ignorable === true;
|
|
253
253
|
if (!unknownIgnorable) {
|
|
@@ -343,7 +343,7 @@ export function engineViolations(event, loc) {
|
|
|
343
343
|
const out = [];
|
|
344
344
|
if (event.type === 'assistant/message' && event.surfaceOp === 'append') {
|
|
345
345
|
// turn/step 位于 event.data 层(实证:正常消息 data.turn/data.step 为数字,
|
|
346
|
-
// 插件 marker data.turn/data.step 为 null —— 复盘事故第 2
|
|
346
|
+
// 插件 marker data.turn/data.step 为 null —— 复盘事故第 2 次尝试)
|
|
347
347
|
const turn = event.data?.turn;
|
|
348
348
|
const step = event.data?.step;
|
|
349
349
|
if (turn == null || step == null) {
|
|
@@ -370,7 +370,7 @@ export function pluginViolations(event, loc) {
|
|
|
370
370
|
return out;
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
/** 折叠终验:**按文件版本**择路(
|
|
373
|
+
/** 折叠终验:**按文件版本**择路(2026-09-14 补强)。
|
|
374
374
|
*
|
|
375
375
|
* - v3 文件 → 运行时导出的官方 `foldSurface`(与运行时同语义);
|
|
376
376
|
* - v0/v1/v2 文件 → 本地 `legacyFoldSurface`(rc.7 `foldSurface` 逐条移植)。
|
|
@@ -847,10 +847,10 @@ export function wireViolations(events, version) {
|
|
|
847
847
|
* `turn/start`(闭合/预期轮)、继承切点、`assistant/attempt` 配对、`stored log corrupt`、
|
|
848
848
|
* v0→v1 对其余事件的形状拒绝。
|
|
849
849
|
*
|
|
850
|
-
* **为什么 `turn/start`
|
|
850
|
+
* **为什么 `turn/start` 不做(实测结论)**:官方那个状态机(`v0-to-v1:2270` 的
|
|
851
851
|
* `assertReleasedArtifactRelationships`)**不是**在原始 v0 事件上跑的——它由 **v1→v2**
|
|
852
852
|
* 以 `RELEASED_V2_RELATIONSHIP_EXTENSIONS` 调用在**变换后的 v1/v2 artifact** 上
|
|
853
|
-
* (`v1-to-v2/lib/index.js:104`),并带 `cut
|
|
853
|
+
* (`v1-to-v2/lib/index.js:104`),并带 `cut`(继承切点)处理。实测:在原始 v0 上照抄该
|
|
854
854
|
* 状态机会在**已 seed 的会话**上狂报(样本(某真实会话):v0→v1 官方并不以该规则拒绝,
|
|
855
855
|
* 而原始 v0 上会报 19 条),属"规则文本对、应用对象错"。要忠实复现必须先把 v0→v1→v2 的
|
|
856
856
|
* 变换做出来 ⇒ 记未覆盖。
|
|
@@ -884,12 +884,12 @@ export function migrationPrecheckViolations(events, version) {
|
|
|
884
884
|
// 判据:每个被引 seq 必须解析到更早的 `user/message`,且 `data.source.kind === 'user'`;
|
|
885
885
|
// 另 `session/title` 的 `source.kind === 'user'` ⟺ `messageSeqs` 空。
|
|
886
886
|
//
|
|
887
|
-
// ⚠️
|
|
887
|
+
// ⚠️ 应用面说明(实测 2026-09-15):官方 `assertTitleSources` 与 `turn/start` 状态机
|
|
888
888
|
// 同在 `assertReleasedArtifactRelationships` 里,而该函数被 **v1→v2** 以
|
|
889
889
|
// `RELEASED_V2_RELATIONSHIP_EXTENSIONS` 调用在**变换后的 v1/v2 artifact** 上
|
|
890
890
|
// (`dsh-session-format-v1-to-v2/lib/index.js:104`),不是原始 v0 事件。
|
|
891
891
|
// 对 `messageSeqs` 这类"按 seq 索引 + 事件类型/source 判定"的引用,变换保序保类型
|
|
892
|
-
// ⇒ 在原始 v0 上判是**必要条件的近似**;实测 281 个真实 v0 上 **0
|
|
892
|
+
// ⇒ 在原始 v0 上判是**必要条件的近似**;实测 281 个真实 v0 上 **0 误报**。
|
|
893
893
|
const bySeq = new Map();
|
|
894
894
|
for (const { event } of events) if (event && typeof event === 'object') bySeq.set(event.seq, event);
|
|
895
895
|
for (const { event, lineNo } of events) {
|
package/lib/contracts.js
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
* DSH 会话日志契约规则目录(spec)。
|
|
5
5
|
*
|
|
6
6
|
* 规则集来源:
|
|
7
|
-
* -
|
|
8
|
-
* - 会话修复事故复盘(三层契约:持久化 / 客户端引擎 / 插件语义)
|
|
7
|
+
* - 历史会话日志的实测事故与契约缺口(持久化 / 客户端引擎 / 插件语义三层)
|
|
9
8
|
* - `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对(见每条 `source`)
|
|
10
9
|
*
|
|
11
10
|
* 每条规则只描述"契约是什么";具体判定逻辑在 `lib/validate.js`(离线体检)
|
|
@@ -39,25 +38,25 @@ export const SEVERITY = { ERROR: 'error', WARNING: 'warning', INFO: 'info' };
|
|
|
39
38
|
* - G 迁移预检(migration gate,**独立维度**:官方迁移会不会拒;不进 ok/verdict)
|
|
40
39
|
*/
|
|
41
40
|
/**
|
|
42
|
-
*
|
|
41
|
+
* (2026-09-14 实测)——**机读的漂移/未复核标注**。
|
|
43
42
|
*
|
|
44
|
-
*
|
|
43
|
+
* 核对结论:规则的 `source` 大量停在 `@deepseek-ai/dsh-session@0.1.0-rc.7` 的行号上,
|
|
45
44
|
* 且其中 4 条(T2/P1/P2/C1)的**判定前提**在真宿主 0.1.5 上已不成立——此前**没有任何机制
|
|
46
45
|
* 能发现**(`source` 只是人读字符串)。这里把它们变成机读字段,并由 `check` 抬头点名。
|
|
47
46
|
*/
|
|
48
47
|
export const SOURCE_DRIFT = Object.freeze({
|
|
49
|
-
/** 出处停在 rc.7 行号、未在 0.1.5
|
|
48
|
+
/** 出处停在 rc.7 行号、未在 0.1.5 上复核(清单见下)。 */
|
|
50
49
|
drifted: Object.freeze(['T2', 'P1', 'P2', 'C1', 'I1', 'G3', 'R2', 'R3', 'E5', 'E6']),
|
|
51
50
|
/**
|
|
52
|
-
* **判定前提在 0.1.5
|
|
51
|
+
* **判定前提在 0.1.5 上不成立/无法判定**:
|
|
53
52
|
* T2 token-meter 已改从 `event.data.stream` 重建(无 sourceEventSeqs/无 belongs to another step);
|
|
54
53
|
* P1/P2 载体已换成 `user/message + data.id`(无 data.editor);C1 前提"无会话级排他锁"被
|
|
55
54
|
* `session.lock` flock 租约证伪;T3/T4 半步需真机实验。⇒ 这些规则的结论**不可单独采信**。
|
|
56
55
|
*/
|
|
57
56
|
premiseStale: Object.freeze(['T2', 'P1', 'P2', 'C1']),
|
|
58
|
-
/**
|
|
57
|
+
/** 明确"无法判定"、需要真机实验的条目(列出来是为了不假装覆盖)。 */
|
|
59
58
|
undecidable: Object.freeze(['T3', 'T4']),
|
|
60
|
-
note: '
|
|
59
|
+
note: '出处/前提漂移是**静默失真**:行为探针(lib/host-probes.js)负责机器可判的那部分,'
|
|
61
60
|
+ '其余在此显式列出,结论抬头必须带漂移清单与未复核清单。',
|
|
62
61
|
});
|
|
63
62
|
|
|
@@ -86,7 +85,7 @@ export const CONTRACT_RULES = [
|
|
|
86
85
|
title: '每行必须是合法 JSON',
|
|
87
86
|
layer: LAYER.PERSISTENCE,
|
|
88
87
|
severity: SEVERITY.ERROR,
|
|
89
|
-
source: '
|
|
88
|
+
source: 'seq 缺口扫描;dsh-session-persistence-jsonl 读路径',
|
|
90
89
|
description: '非空行无法 JSON.parse = 损坏行。帧边界产生的空行是合法的(跳过)。',
|
|
91
90
|
},
|
|
92
91
|
{
|
|
@@ -122,7 +121,7 @@ export const CONTRACT_RULES = [
|
|
|
122
121
|
title: 'seq 严格连续(单写入者假设)',
|
|
123
122
|
layer: LAYER.PERSISTENCE,
|
|
124
123
|
severity: SEVERITY.ERROR,
|
|
125
|
-
source: '@deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous")
|
|
124
|
+
source: '@deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous")',
|
|
126
125
|
description: 'seq 必须从 0(或窗口 baseSeq)严格连续递增。缺口/倒退 = 违反单写入者假设(多实例共享存储并发写的痕迹),加载时直接 throw。',
|
|
127
126
|
},
|
|
128
127
|
{
|
|
@@ -165,7 +164,7 @@ export const CONTRACT_RULES = [
|
|
|
165
164
|
layer: LAYER.PERSISTENCE,
|
|
166
165
|
severity: SEVERITY.ERROR,
|
|
167
166
|
source: '@deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)',
|
|
168
|
-
description: 'request/header-delta 与 reason=fallback 的 request/header
|
|
167
|
+
description: 'request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式。注意(订正 E5):宿主 Session.append/appendLines 不看 type ⇒ 写入会成功、下一次读取才炸(依据 dsh-session@0.1.5-rc.1 lib/index.js:1170-1210 / persistence-jsonl:3046-3073),不是写入即被拒。',
|
|
169
168
|
},
|
|
170
169
|
{
|
|
171
170
|
id: 'E6',
|
|
@@ -215,8 +214,8 @@ export const CONTRACT_RULES = [
|
|
|
215
214
|
title: 'replace 的 sourceEventSeqs 必须完整覆盖被替换节点',
|
|
216
215
|
layer: LAYER.PERSISTENCE,
|
|
217
216
|
severity: SEVERITY.ERROR,
|
|
218
|
-
source: '@deepseek-ai/dsh-session lib/index.js:335-336 (assertProvenance);复盘事故第 1
|
|
219
|
-
description: '★ 写前校验核心规则:sourceEventSeqs 必须包含每一个被替换(shadowed)的 surface 节点,缺一个 = 会话加载被拒(SessionPersistenceCorruptionError)。2026-08-25 事故第 1
|
|
217
|
+
source: '@deepseek-ai/dsh-session lib/index.js:335-336 (assertProvenance);复盘事故第 1 次尝试',
|
|
218
|
+
description: '★ 写前校验核心规则:sourceEventSeqs 必须包含每一个被替换(shadowed)的 surface 节点,缺一个 = 会话加载被拒(SessionPersistenceCorruptionError)。2026-08-25 事故第 1 次尝试(清空 sourceEventSeqs)正是违反此规则。',
|
|
220
219
|
},
|
|
221
220
|
{
|
|
222
221
|
id: 'S6',
|
|
@@ -290,20 +289,20 @@ export const CONTRACT_RULES = [
|
|
|
290
289
|
title: 'ignorable 未知 type 合法性(带被忽略标记的未知事件须有消费者)',
|
|
291
290
|
layer: LAYER.PERSISTENCE,
|
|
292
291
|
severity: SEVERITY.WARNING,
|
|
293
|
-
source: '
|
|
292
|
+
source: '实测 2026-09-09 T2(E3 ignorable 无合法性校验 = 后门)',
|
|
294
293
|
description: '未知 type + ignorable:true 被读路径接纳但无人消费 = 静默垃圾。排除已知消费者白名单(retrace/marker、retrace/goal-marker、message-editor/ 前缀等 retrace 客户端消费的插件 marker)后,其余 ignorable 未知事件报 warning。',
|
|
295
294
|
},
|
|
296
295
|
{
|
|
297
296
|
id: 'E8',
|
|
298
297
|
title: '事件信封键白名单(多余键:seed/restore 路径会拒)',
|
|
299
298
|
layer: LAYER.PERSISTENCE,
|
|
300
|
-
//
|
|
299
|
+
// 候选规则 + 探针订正:宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1
|
|
301
300
|
// lib/index.js:849-861)确实拒多余键,但其**唯一调用点是 Session 构造器的 seed 路径**
|
|
302
301
|
// (:1063-1068);JSONL **load 路径**(`adoptSessionEvent`)实测**容忍**(探针 p5 钉住)。
|
|
303
302
|
// 故本规则按 **warning** 报(不影响"可加载",但该日志作为 seed/restore 输入会被拒)。
|
|
304
303
|
severity: SEVERITY.WARNING,
|
|
305
304
|
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
|
|
305
|
+
description: '事件对象只允许 7 个信封键(type/seq/time/data/surfaceOp/sourceEventSeqs/ignorable)。实测反例指出"宿主拒、旧契约 0 违规";行为探针进一步订正口径:**load 路径容忍、seed/restore 路径拒** ⇒ warning。',
|
|
307
306
|
candidate: true,
|
|
308
307
|
},
|
|
309
308
|
{
|
|
@@ -312,7 +311,7 @@ export const CONTRACT_RULES = [
|
|
|
312
311
|
layer: LAYER.PERSISTENCE,
|
|
313
312
|
severity: SEVERITY.ERROR,
|
|
314
313
|
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
|
|
314
|
+
description: 'v3 新增的 system/message:role 必须为 system,source.kind 必须为 plugin 且 plugin 非空。实测(反例):source.kind=\'user\' 宿主拒、旧契约 0 违规 ⇒ 漏检。',
|
|
316
315
|
candidate: true,
|
|
317
316
|
},
|
|
318
317
|
{
|
|
@@ -321,7 +320,7 @@ export const CONTRACT_RULES = [
|
|
|
321
320
|
layer: LAYER.PERSISTENCE,
|
|
322
321
|
severity: SEVERITY.ERROR,
|
|
323
322
|
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
|
|
323
|
+
description: 'request/header 必须省略 header.system(系统提示改走 system/message)、空 tools、空 adapterDefaults。实测(反例):带 header.system 的写入宿主拒、旧契约 0 违规 ⇒ 漏检。',
|
|
325
324
|
candidate: true,
|
|
326
325
|
},
|
|
327
326
|
{
|
|
@@ -329,7 +328,7 @@ export const CONTRACT_RULES = [
|
|
|
329
328
|
title: '空会话文件(有 header 无事件)显式报出',
|
|
330
329
|
layer: LAYER.FRAMING,
|
|
331
330
|
severity: SEVERITY.WARNING,
|
|
332
|
-
source: '
|
|
331
|
+
source: '实测 2026-09-09 T3(36 条规则全来自有内容事故,空态无覆盖)',
|
|
333
332
|
description: '有 header 但零事件 = 异常空会话(新建即空或写入未落盘)。空态不在任何有内容规则的覆盖下,显式 warning 供人判断。',
|
|
334
333
|
},
|
|
335
334
|
{
|
|
@@ -354,7 +353,7 @@ export const CONTRACT_RULES = [
|
|
|
354
353
|
title: 'turn/step 为 null 的 assistant/message 只能 replace,不能 append',
|
|
355
354
|
layer: LAYER.ENGINE,
|
|
356
355
|
severity: SEVERITY.ERROR,
|
|
357
|
-
source: '复盘事故第 2
|
|
356
|
+
source: '复盘事故第 2 次尝试(rt.js:6816 崩溃);实证 data.turn/data.step:正常消息为数字、插件 marker 为 null',
|
|
358
357
|
description: 'data.turn/data.step 为 null 的 assistant/message(如插件 marker)只能以 replace 承载(走插件 marker 定义);作为 append 会落进核心 assistant-step 定义,因 turn=null 发布 location data 导致客户端引擎崩溃。',
|
|
359
358
|
},
|
|
360
359
|
|
|
@@ -388,7 +387,7 @@ export const CONTRACT_RULES = [
|
|
|
388
387
|
title: 'seq 缺口/倒退提示多写入者',
|
|
389
388
|
layer: LAYER.CONCURRENCY,
|
|
390
389
|
severity: SEVERITY.WARNING,
|
|
391
|
-
source: '
|
|
390
|
+
source: 'dsh-session-persistence-jsonl appendLines 无锁(:1200-1227),全仓无会话级排他锁',
|
|
392
391
|
description: '离线体检无法直接观测跨进程竞态,但 E2 暴露的缺口/倒退即是"≥2 个 Host 进程共享同一 session 目录并发写"的后果。单实例部署不触发。',
|
|
393
392
|
},
|
|
394
393
|
|
|
@@ -398,7 +397,7 @@ export const CONTRACT_RULES = [
|
|
|
398
397
|
title: 'zstd 尾帧撕裂',
|
|
399
398
|
layer: LAYER.FRAMING,
|
|
400
399
|
severity: SEVERITY.WARNING,
|
|
401
|
-
source: '
|
|
400
|
+
source: 'zstd 帧布局(dsh-session-persistence-jsonl 多帧)',
|
|
402
401
|
description: '尾帧不完整(torn):可能正在写入(in-flight)或文件被截断。若这是唯一异常,通常可等待写入完成;若持续存在则是截断证据。',
|
|
403
402
|
},
|
|
404
403
|
{
|
|
@@ -406,9 +405,25 @@ export const CONTRACT_RULES = [
|
|
|
406
405
|
title: 'zstd 帧解码失败 = 单帧全损',
|
|
407
406
|
layer: LAYER.FRAMING,
|
|
408
407
|
severity: SEVERITY.ERROR,
|
|
409
|
-
source: '
|
|
408
|
+
source: '多帧单帧全损 → 整会话不可读',
|
|
410
409
|
description: '任一帧解码失败(磁盘 bitrot / 传输截断 / 并发写撕裂)即整会话不可读;帧越多,单帧损坏下丢失概率线性上升。',
|
|
411
410
|
},
|
|
411
|
+
{
|
|
412
|
+
id: 'Z4',
|
|
413
|
+
title: '首帧必须恰好一行 header',
|
|
414
|
+
layer: LAYER.FRAMING,
|
|
415
|
+
severity: SEVERITY.ERROR,
|
|
416
|
+
source: '宿主 assertZstdHeaderFrame(0.1.5-rc.2 lib/index.js:2184):首帧非"恰好一行"即抛',
|
|
417
|
+
description: '官方写盘形状是 header **独占第一帧**。首帧装多行时宿主抛错,且抛点在 listArtifacts **遍历全库**、无 per-session 容错 ⇒ **整个客户端起不来**(2026-09-16 真机事故:有人把整份日志重压成单帧写回)。',
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
id: 'Z5',
|
|
421
|
+
title: '多行日志不得压成单帧',
|
|
422
|
+
layer: LAYER.FRAMING,
|
|
423
|
+
severity: SEVERITY.ERROR,
|
|
424
|
+
source: '官方形状:header 第一帧 + 事件批次逐帧追加',
|
|
425
|
+
description: '把整份日志重压成 1 帧会同时触发 Z4;任何"重写会话"的路径都必须**保形**(逐帧解、逐帧回写,或走宿主写入器),不得整体重压缩。',
|
|
426
|
+
},
|
|
412
427
|
|
|
413
428
|
// ── W · wire 消息流(模型请求序列)───────────────────────────────────────
|
|
414
429
|
{
|
package/lib/host-probes.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-log-contract · lib/host-probes.js —— **行为探针**(漂移检测的正解)
|
|
3
|
-
* (2026-09-14
|
|
3
|
+
* (2026-09-14)
|
|
4
4
|
*
|
|
5
|
-
*
|
|
5
|
+
* 为什么需要它:本包"承重墙建错了地方"——**委托宿主运行时函数**的判据基本正确,
|
|
6
6
|
* **作者手写镜像/自建模型**的判据成片出错,而 `hostPackageVersion()`/`hostCapability()`
|
|
7
7
|
* **只打印不门禁**、规则的 `source` 是**不可机读的字符串** ⇒ 本次 5 类失准**没有任何机制能发现**。
|
|
8
8
|
*
|
|
@@ -84,7 +84,7 @@ export const PROBES = [
|
|
|
84
84
|
title: '信封多余键:load 路径(adoptSessionEvent)**容忍**,只在 seed 路径拒(E8 的口径)',
|
|
85
85
|
rules: ['E8'],
|
|
86
86
|
expect: 'adoptSessionEvent 不拒(宿主 load 路径容忍)',
|
|
87
|
-
//
|
|
87
|
+
// 这条探针**修正了一条规则的过度声称**(探针的价值实证):
|
|
88
88
|
// · 宿主 `assertSessionEventEnvelope`(dsh-session@0.1.5-rc.1 lib/index.js:849-861)确实拒多余键,
|
|
89
89
|
// 但它的**唯一调用点**是 Session 构造器的 **seed 路径**(:1063-1068)——不是 JSONL load 路径;
|
|
90
90
|
// · load 路径(`adoptSessionEvent`)实测**容忍**多余键。
|
package/lib/log-reader.js
CHANGED
|
@@ -5,11 +5,10 @@
|
|
|
5
5
|
* `decodeStorageRecord` 展开(chunk 行展开 / 损坏行报错)。
|
|
6
6
|
*
|
|
7
7
|
* 契约来源:
|
|
8
|
-
* -
|
|
9
|
-
* 帧头布局对齐 zstd 规范(magic 0xFD2FB528、descriptor、block 头)。
|
|
8
|
+
* - 帧扫描/撕裂尾帧判定:按 zstd 规范逐帧扫(magic 0xFD2FB528、descriptor、block 头)。
|
|
10
9
|
* - 行解码:本地兼容层 `./compat.js` 的 `decodeStorageRecord`(原官方导出于 0.1.5 移除)
|
|
11
10
|
* (lib/index.js:1029,validateRow :922 / expandRow :973)。
|
|
12
|
-
* - 损坏语义:
|
|
11
|
+
* - 损坏语义:chunk 行损坏 = 整段 run 丢失且加载失败(dsh-session
|
|
13
12
|
* lib/index.js:1022-1024 注释明示 fail-loud,无跳过逃生舱)。
|
|
14
13
|
*/
|
|
15
14
|
import fs from 'node:fs';
|
|
@@ -78,7 +77,7 @@ export function scanZstdFrames(buf) {
|
|
|
78
77
|
/** 把 zstd 多帧拼成完整明文;任一帧解码失败即抛错(N5 单帧全损语义)。 */
|
|
79
78
|
export function decompressZstd(buf, { allowTorn = true } = {}) {
|
|
80
79
|
const { frames, torn } = scanZstdFrames(buf);
|
|
81
|
-
// ──
|
|
80
|
+
// ── 2026-09-14 实测:这里的 Z2 是误报 ───────────────────────────────────
|
|
82
81
|
// 撕裂尾帧**不是**损坏:宿主 `dsh-session-persistence-jsonl@0.1.5-rc.1`
|
|
83
82
|
// · `readZstdPrefix`(lib/index.js:2791-2849)——"Decode complete frames and retain
|
|
84
83
|
// complete JSONL records from a torn final frame",恢复并返回 `tornTruncateTo`(:2847);
|
|
@@ -174,7 +173,19 @@ export function loadSessionLog(path) {
|
|
|
174
173
|
plain = Buffer.alloc(0);
|
|
175
174
|
}
|
|
176
175
|
const { frames, torn } = scanZstdFrames(buf);
|
|
177
|
-
|
|
176
|
+
// 帧形状事实(Z4/Z5,2026-09-16 真机事故):官方写盘形状 = **header 独占第一帧** +
|
|
177
|
+
// 事件逐帧追加;读取端有硬断言 `assertZstdHeaderFrame`(首帧必须**恰好一行**)。
|
|
178
|
+
// 这里把该事实带出来给 validate 判:整文件被重压成单帧时,首帧装的是全部行 ⇒ 宿主必抛,
|
|
179
|
+
// 而抛点在 `listArtifacts` **遍历全库**、无 per-session 容错 ⇒ 整个客户端起不来。
|
|
180
|
+
let firstFrameOneLine = true, firstFrameLines = 1;
|
|
181
|
+
try {
|
|
182
|
+
const [s0, e0] = frames[0];
|
|
183
|
+
const first = zstdDecompressSync(buf.subarray(s0, e0)).toString('utf8');
|
|
184
|
+
const nl = first.indexOf('\n');
|
|
185
|
+
firstFrameOneLine = nl === first.length - 1 && first.length > 1;
|
|
186
|
+
firstFrameLines = first.split('\n').filter((x) => x !== '').length;
|
|
187
|
+
} catch { /* 首帧解码失败另有 Z2 报 */ }
|
|
188
|
+
frameInfo = { frames: frames.length, torn, compressedBytes: buf.length, plaintextBytes: plain.length, firstFrameOneLine, firstFrameLines };
|
|
178
189
|
} else {
|
|
179
190
|
plain = buf;
|
|
180
191
|
frameInfo = { frames: 0, torn: false, compressedBytes: 0, plaintextBytes: plain.length };
|
package/lib/prewrite.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* ★ 写前校验(pre-write validation)——本工具的第一公民。
|
|
5
5
|
*
|
|
6
|
-
* 复盘事故(2026-08-25)第 1
|
|
6
|
+
* 复盘事故(2026-08-25)第 1 次尝试失败就是"违约写入没被拦":surface-replace
|
|
7
7
|
* 的 `sourceEventSeqs` 被清空后写入,会话加载直接抛
|
|
8
8
|
* `SessionPersistenceCorruptionError`。如果写入前先校验,会话根本不会被改坏。
|
|
9
9
|
*
|
|
@@ -28,10 +28,10 @@ import { normalizeEventSeqRanges } from './compat.js';
|
|
|
28
28
|
const LEGACY_MARKER_ID_PREFIXES = ['retrace', 'message-editor'];
|
|
29
29
|
|
|
30
30
|
/**
|
|
31
|
-
* **可识别的历史 retrace 载体**(
|
|
31
|
+
* **可识别的历史 retrace 载体**(2026-09-14 实测:白名单收窄)。
|
|
32
32
|
*
|
|
33
33
|
* 旧实现只判"任意 `data.editor` 存在" ⇒ **任何**写 `assistant/message` replace + `data.editor`
|
|
34
|
-
* 的第三方插件都能领 T1
|
|
34
|
+
* 的第三方插件都能领 T1 豁免(实测判定:"设计粗糙的豁口")。收窄为四条同时成立:
|
|
35
35
|
* ① 类型/操作:`assistant/message` + replace;
|
|
36
36
|
* ② 载体标记:`data.editor !== undefined`;
|
|
37
37
|
* ③ **身份**:`data.message.id` 带 retrace 历史 marker 前缀(`retrace-*` / `message-editor-*`);
|
|
@@ -66,9 +66,9 @@ function normalizeCandidate(candidate, nextSeq) {
|
|
|
66
66
|
* **格式版本(C2)**:`formatVersion` > `header.version` > 事件形状推断 > 0,在**本次
|
|
67
67
|
* `createPreWriter` 调用内固定**并显式传给每条按版本择路的判定。**不再读写模块级全局**
|
|
68
68
|
* `fileVersion`——旧实现下同一进程"先 validate(v3) 再 prewrite(v0)"会把同一份合法 v0 输入
|
|
69
|
-
* 的结论翻成 S4+S8
|
|
69
|
+
* 的结论翻成 S4+S8(实测复现)。下游 `dsh-retrace` 正是只传 `events` 直接调用
|
|
70
70
|
* (`lib/prewrite-guard.js:166`),所以缺省时必须能自行推断,不能把 v3 输入按 0 处理
|
|
71
|
-
* (否则首次调用即 10 条 E3/S2/S8
|
|
71
|
+
* (否则首次调用即 10 条 E3/S2/S8 误报)。
|
|
72
72
|
*
|
|
73
73
|
* @param {{ events: Array<object>, baseSeq?: number, formatVersion?: number, header?: object|null }} input
|
|
74
74
|
* 当前日志的已解码事件(按日志顺序;无 seq 字段的事件按位置补 seq,用于窗口校验)。
|
|
@@ -136,7 +136,7 @@ export function createPreWriter(input = {}) {
|
|
|
136
136
|
// (check),不在这里重复拦截(否则历史 marker 会让后续编辑全部被拒)。
|
|
137
137
|
const lastCandidate = candidateEvents[candidateEvents.length - 1];
|
|
138
138
|
const legacyKind = legacyMarkerKindOf(lastCandidate);
|
|
139
|
-
//
|
|
139
|
+
// 版本门(2026-09-14):降级**只对 ≤v2 文件**。
|
|
140
140
|
// 依据:新载体(`user/message` + `data.id`,retrace 0.4.26)根本不带 `data.editor`,在 v3 上
|
|
141
141
|
// 降级**救不回任何写入**(v3 禁 assistant/message 带 provenance ⇒ S8 兜住) —— 留在 v3 上
|
|
142
142
|
// 只会掩盖 T1 的真实原因。v0/v1/v2 才是有价值的作用域(回放/重写历史形态 marker)。
|
|
@@ -144,7 +144,7 @@ export function createPreWriter(input = {}) {
|
|
|
144
144
|
for (const t1 of tokenMeterViolations(candidateEvents.map((event) => ({ event })))) {
|
|
145
145
|
if (t1.id !== 'T1' || t1.seq !== lastCandidate?.seq) continue;
|
|
146
146
|
if (legacyKind && legacyDowngradeAllowed) {
|
|
147
|
-
//
|
|
147
|
+
// 降级**可见**:违规里带 markerKind/id/targetSeq,并由结果字段 `legacyMarkerDebt`
|
|
148
148
|
// 显式带出;入口(CLI)打印"压缩前需一次性清理",不再"记了没人看"。
|
|
149
149
|
violations.push({
|
|
150
150
|
...t1,
|
|
@@ -179,7 +179,7 @@ export function createPreWriter(input = {}) {
|
|
|
179
179
|
}
|
|
180
180
|
const bySeverity = { error: 0, warning: 0, info: 0 };
|
|
181
181
|
for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
|
|
182
|
-
//
|
|
182
|
+
// 「让降级可见」:把"本次写入沿用了历史 marker 形态(债)"作为**结构化字段**带出,
|
|
183
183
|
// 入口据此打印"压缩前需一次性清理"。降级不再只是 violations 里的一句 warning。
|
|
184
184
|
const legacyDebt = legacyKind && legacyDowngradeAllowed && bySeverity.error === 0
|
|
185
185
|
? { kind: legacyKind.prefix, id: legacyKind.id, targetSeq: legacyKind.targetSeq, seq: lastCandidate?.seq ?? null, formatVersion }
|
package/lib/repair.js
CHANGED
|
@@ -209,7 +209,7 @@ export function neutralizeMarkersText(text, { onlyLegacy = false } = {}) {
|
|
|
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
|
-
//
|
|
212
|
+
// 一次性根治路径(`--neutralize-legacy-markers`):只动**历史载体**
|
|
213
213
|
// (`assistant/message` + `data.editor`);新载体是 `user/message` + `data.id`,
|
|
214
214
|
// 本来就不在这个分支里(类型不符),这里额外的判据是"必须有 editor",
|
|
215
215
|
// 便于把"清历史债"与"泛化中和"区分开、也让报告口径可核对。
|
|
@@ -423,15 +423,14 @@ function renumberWithDrops(parts, dropPredicate, dropLinePredicate = null) {
|
|
|
423
423
|
*
|
|
424
424
|
* ⚠️ 与 renumberWithDrops 不同:本函数按「行是否保留」决定删除(双流交织时
|
|
425
425
|
* 保留行与删除行可能共享 seq 值,按 seq 值判删会误删保留行——2026-08-31
|
|
426
|
-
*
|
|
427
|
-
* keep-ranges.mjs 的 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
|
|
426
|
+
* 实测复现),且保留行**全量重映射** seq 从 0 连续(与 `--keep-ranges` 同一 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
|
|
428
427
|
* surfaceOp 范围。header 行(首行)恒保留且不改。
|
|
429
428
|
* @param {string[]} parts - 按 \n 切分的行数组。
|
|
430
429
|
* @param {(lineIdx: number) => boolean} keepLinePredicate - 行身份保留判定。
|
|
431
430
|
* @returns {{ text: string, removed: number, renumbered: number }}
|
|
432
431
|
*/
|
|
433
432
|
function renumberKeptLines(parts, keepLinePredicate) {
|
|
434
|
-
// 第一遍:按物理序收集保留行的事件 seq(chunk
|
|
433
|
+
// 第一遍:按物理序收集保留行的事件 seq(chunk 行展开,与 --keep-ranges 同口径)
|
|
435
434
|
const keptLineIdx = [];
|
|
436
435
|
for (let i = 0; i < parts.length; i++) {
|
|
437
436
|
const raw = parts[i];
|
|
@@ -559,7 +558,7 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
559
558
|
const parts = text.split('\n');
|
|
560
559
|
const out = [];
|
|
561
560
|
let changed = 0;
|
|
562
|
-
// ── seq 引用平移(2026-09-14
|
|
561
|
+
// ── seq 引用平移(2026-09-14:并补两处同族缺口)──
|
|
563
562
|
// 一手依据(官方 v0→v1 校验,@0.1.5-rc.2):
|
|
564
563
|
// - `data.shadowedRange{start,end}` + `data.shadowedSeqs`:`dsh-session-format-v0-to-v1`
|
|
565
564
|
// `lib/index.js:55-70`(compaction/prune、compaction/summary 的 dispositions)、
|
|
@@ -655,7 +654,7 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
655
654
|
* 重新排队 → UI 显示"待排队消息")。与 neutralize 同类:原地改
|
|
656
655
|
* `removedCount → 0`(start/inserted 不变,seq/行数不变 → 附着力安全)。
|
|
657
656
|
*
|
|
658
|
-
* ⚠️ 判定(2026-08-31
|
|
657
|
+
* ⚠️ 判定(2026-08-31 修正):不能对"所有 removedCount>0 的
|
|
659
658
|
* next-turn spliced"下手——DSH 每个轮次消费消息都会写这种 spliced
|
|
660
659
|
* (archive 实测 3069 处 removedCount>0,但真 I1 违规只 2 处)。只归零
|
|
661
660
|
* **实际触发 I1 违规**的那条:按官方 inbox 重放(checks.js
|
|
@@ -724,10 +723,10 @@ export function neutralizeOrphanText(text) {
|
|
|
724
723
|
export function extractTurnText(text, keepTurn, secondTurnTo = null) {
|
|
725
724
|
const parts = text.split('\n');
|
|
726
725
|
const KEEP_NULL_TYPES = new Set(['agent/inbox/spliced', 'user/message', 'request/header', 'session/end-seed', 'command/run', 'command/done']);
|
|
727
|
-
//
|
|
726
|
+
// 第一遍:按行身份决定去留(保留 turn===keepTurn 的行 + 无 turn 系统事件
|
|
728
727
|
// 白名单),并就地改第二个同名轮次的 turn 号。
|
|
729
|
-
// ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq
|
|
730
|
-
//
|
|
728
|
+
// ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq(实测
|
|
729
|
+
// 复现内容静默丢失)——按行身份删除才安全。
|
|
731
730
|
const dropLines = new Set();
|
|
732
731
|
let sawFirstTurnStart = false;
|
|
733
732
|
let inRenumberTurn = false;
|
|
@@ -769,8 +768,7 @@ export function extractTurnText(text, keepTurn, secondTurnTo = null) {
|
|
|
769
768
|
}
|
|
770
769
|
|
|
771
770
|
/**
|
|
772
|
-
* 只保留指定 seq 区间,其余删除 +
|
|
773
|
-
* 源:tools/keep-ranges.mjs)。
|
|
771
|
+
* 只保留指定 seq 区间,其余删除 + 全量重编号(`--keep-ranges`)。
|
|
774
772
|
*
|
|
775
773
|
* 从交织/污染文件中提取干净区段。区间为 1-based 行号(含端点),
|
|
776
774
|
* 如 "10-20,40-50";区段外的行丢弃。header 行(首行)永远保留。
|
|
@@ -788,7 +786,7 @@ export function keepRangesText(text, rangesSpec) {
|
|
|
788
786
|
return [a, b];
|
|
789
787
|
});
|
|
790
788
|
const parts = text.split('\n');
|
|
791
|
-
// 按行身份删除(双流交织时保留行与删除行可能共享 seq
|
|
789
|
+
// 按行身份删除(双流交织时保留行与删除行可能共享 seq——实测发现)
|
|
792
790
|
const dropLines = new Set();
|
|
793
791
|
let keptLines = 0;
|
|
794
792
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -1158,7 +1156,7 @@ export function repairSession(file, opts = {}) {
|
|
|
1158
1156
|
let plain;
|
|
1159
1157
|
if (isZstd) {
|
|
1160
1158
|
try {
|
|
1161
|
-
//
|
|
1159
|
+
// 边界:**写路径不吃撕裂尾帧**。`check`(只读)按宿主语义恢复撕裂尾帧;但
|
|
1162
1160
|
// `fix` 会回写文件,若尾帧是**活动会话正在写入**的部分,回写会把它截掉 ——
|
|
1163
1161
|
// 与宿主"由持有租约的会话自己截断"(:226-228)不同责。故这里显式 strict。
|
|
1164
1162
|
plain = decompressZstd(buf, { allowTorn: false }).toString('utf8');
|
package/lib/validate.js
CHANGED
|
@@ -60,14 +60,14 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
60
60
|
// C1 防御(多入口一致):即使调用方手搓 log 对象、未经 `loadSessionLog`,也在体检入口
|
|
61
61
|
// 归一一次 v3 区间编码。正常路径(loadSessionLog 已展开)下这是幂等的恒等映射。
|
|
62
62
|
const events = (log.events ?? []).map((e) => ({ ...e, event: normalizeEventSeqRanges(e.event) }));
|
|
63
|
-
//
|
|
63
|
+
// 词表/折叠路径按**被检文件自身版本**选择(不按运行时)。
|
|
64
64
|
// C2:版本是**本次调用的局部量**,显式传给每条按版本择路的判定——不再写模块级全局
|
|
65
65
|
// (旧实现 `setFileFormatVersion()` 会被同进程后调用的 prewrite 读到,造成结论翻转)。
|
|
66
66
|
const formatVersion = Number.isSafeInteger(header?.version) && header.version >= 0 ? header.version : 0;
|
|
67
67
|
|
|
68
68
|
// ── 宿主能力闸(S2·安全)──────────────────────────────────────────────────
|
|
69
69
|
// 被检文件版本 > 本宿主支持的最大文件版本 ⇒ 本宿主没有该版本的词表/折叠语义。继续按
|
|
70
|
-
//
|
|
70
|
+
// 本宿主语义判定会产出**假阳性**(实测:rc.7 上同一真实 v3 文件 →
|
|
71
71
|
// `S8×1 + E3×40 / verdict=broken / loadable:false`),危险是用户以为日志坏了去跑
|
|
72
72
|
// `fix --apply`。改为:只跑与版本无关的结构检查 + 显式"不可在本宿主评估"档。
|
|
73
73
|
if (!isAssessableFileVersion(formatVersion)) {
|
|
@@ -81,6 +81,16 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
81
81
|
if (frameInfo?.error) {
|
|
82
82
|
violations.push(violation('Z2', { lineNo: null }, frameInfo.error));
|
|
83
83
|
}
|
|
84
|
+
// Z4 · 首帧必须恰好一行 header(2026-09-16 真机事故:整份日志被重压成单帧 ⇒ 客户端起不来)
|
|
85
|
+
if (frameInfo?.firstFrameOneLine === false) {
|
|
86
|
+
violations.push(violation('Z4', { lineNo: null },
|
|
87
|
+
`首帧不是"恰好一行 header"(实测 ${frameInfo.firstFrameLines ?? '?'} 行)——宿主 assertZstdHeaderFrame 必抛;抛点在 listArtifacts 遍历全库 ⇒ 整个客户端不可用`));
|
|
88
|
+
}
|
|
89
|
+
// Z5 · 多行日志不得压成单帧
|
|
90
|
+
if (Number.isSafeInteger(frameInfo?.frames) && frameInfo.frames === 1 && Array.isArray(rows) && rows.length > 1) {
|
|
91
|
+
violations.push(violation('Z5', { lineNo: null },
|
|
92
|
+
`整份日志被压成 1 帧(${rows.length} 行)——官方形状是 header 独占第一帧 + 事件逐帧追加;不要这样写回`));
|
|
93
|
+
}
|
|
84
94
|
|
|
85
95
|
// ── H · header ─────────────────────────────────────────────────────────
|
|
86
96
|
if (header === null) {
|
|
@@ -225,7 +235,7 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
225
235
|
// 版本支持面(2026-09-14 用户要求第 1 条):宿主版本 vs 测试基线 + 被检文件格式版本,
|
|
226
236
|
// 给出 warnings/readOnly。只读档由入口(prewrite/fix)强制执行。
|
|
227
237
|
result.support = detectSupport({ header, events });
|
|
228
|
-
//
|
|
238
|
+
// 漂移检测:行为探针(宿主自己的运行时函数当 oracle)+ 出处/前提漂移清单。
|
|
229
239
|
result.probes = hostProbes();
|
|
230
240
|
result.drift = driftOf(result.probes);
|
|
231
241
|
result.migration = migrationVerdict(result);
|
|
@@ -353,11 +363,11 @@ function hostProbes() {
|
|
|
353
363
|
}
|
|
354
364
|
|
|
355
365
|
/**
|
|
356
|
-
*
|
|
366
|
+
* 合并出的**漂移报告**(`check` 抬头与 `--json` 共用)。
|
|
357
367
|
* · `unverifiedRules` —— 行为探针在本宿主上**不成立**的规则(宿主语义与规则假设不符);
|
|
358
|
-
* · `driftedSources` —— 出处停在 rc.7、未在 0.1.5
|
|
359
|
-
* · `premiseStale` ——
|
|
360
|
-
* · `fixApplyBlocked` —— 只要任一非空,就**不得据此跑 `fix --apply
|
|
368
|
+
* · `driftedSources` —— 出处停在 rc.7、未在 0.1.5 上复核(机读清单);
|
|
369
|
+
* · `premiseStale` —— 判定前提已不成立/无法判定;
|
|
370
|
+
* · `fixApplyBlocked` —— 只要任一非空,就**不得据此跑 `fix --apply`**。
|
|
361
371
|
*/
|
|
362
372
|
export function driftOf(probes) {
|
|
363
373
|
const p = probes ?? hostProbes();
|
|
@@ -371,7 +381,7 @@ export function driftOf(probes) {
|
|
|
371
381
|
premiseStale,
|
|
372
382
|
undecidable: [...SOURCE_DRIFT.undecidable],
|
|
373
383
|
// `fix --apply` 的**硬前提**只看行为探针(宿主语义与规则假设不一致时才禁止写入类动作);
|
|
374
|
-
//
|
|
384
|
+
// 出处/前提清单是**已知文档债**,会显式告警但不阻断修复流程 —— 否则只要历史
|
|
375
385
|
// 清单非空,"修一个坏会话"这条主路径就永久不可用(见报告"我未照做之处")。
|
|
376
386
|
fixApplyBlocked: unverifiedRules.length > 0,
|
|
377
387
|
note: SOURCE_DRIFT.note,
|
|
@@ -397,11 +407,11 @@ export function migrationVerdict(result) {
|
|
|
397
407
|
'G3 v0 源:session/title 系列 messageSeqs 必须引用更早的人类 user/message(含"空 ⟺ 用户标题")',
|
|
398
408
|
],
|
|
399
409
|
uncovered: [
|
|
400
|
-
// 2026-09-15
|
|
410
|
+
// 2026-09-15:`turn/start` 试做后在**真实语料**上被判定为"应用对象错"——
|
|
401
411
|
// 官方状态机(assertReleasedArtifactRelationships)由 v1→v2 在**变换后的 v1/v2 artifact**
|
|
402
412
|
// 上调用(v1-to-v2/lib/index.js:104),带 cut/继承切点处理;在原始 v0 上照抄会在已 seed
|
|
403
413
|
// 的会话上狂报(实测样本:0.1.5 链并不以该规则拒它,原始 v0 上会报 19 条)。
|
|
404
|
-
// ⇒ 忠实复现需先做 v0→v1→v2
|
|
414
|
+
// ⇒ 忠实复现需先做 v0→v1→v2 变换;暂不做,保持未覆盖。
|
|
405
415
|
'turn/start 闭合/预期轮(官方 v1→v2 在**变换后** artifact 上判:does not close the prior turn / does not open expected turn)',
|
|
406
416
|
'assistant/attempt 配对/闭合(migration refuses the transformed artifact)',
|
|
407
417
|
'Session inheritedEventCut / 继承切点(官方在变换后 artifact 上按 cut 判;header 只有 seedLength)',
|
|
@@ -428,7 +438,7 @@ export function migrationVerdict(result) {
|
|
|
428
438
|
hostMaxFileVersion: HOST_MAX_FILE_VERSION,
|
|
429
439
|
blocked,
|
|
430
440
|
coverage,
|
|
431
|
-
// 2026-09-14
|
|
441
|
+
// 2026-09-14:待迁移文件(applies=true)**只**被 2 条 G 规则覆盖,
|
|
432
442
|
// 另有 6 类官方迁移规则未覆盖 ⇒ 评估范围只能是 "partial"。工具无法逐文件知道
|
|
433
443
|
// 本文件是否命中未覆盖类(那要先把 6 类实现出来或跑官方迁移),但它**知道**这件事。
|
|
434
444
|
assessmentScope: applies ? 'partial' : 'full',
|
package/lib/version-support.js
CHANGED
|
@@ -104,7 +104,7 @@ export function detectSupport({ header, events, formatVersion, hostVersion: host
|
|
|
104
104
|
// ── 被检文件格式 ────────────────────────────────────────────────────────
|
|
105
105
|
const resolved = resolveFormatVersionDetailed({ formatVersion, header, events });
|
|
106
106
|
const unknownSource = resolved.source === 'default';
|
|
107
|
-
// S4
|
|
107
|
+
// S4(实测):无法识别格式时**状态标签必须是 `unverified`**(README 就是这么写的:
|
|
108
108
|
// "Format unrecognisable → same: "unverified format" + read-only")。旧实现让版本回落到默认 0
|
|
109
109
|
// ⇒ 标签打印成 `legacy`,与 README 不一致(`readOnly` 本来就对)。
|
|
110
110
|
const status = unknownSource ? 'unverified' : fileStatusOf(resolved.version);
|