dsh-log-contract 0.3.11 → 0.3.13

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 CHANGED
@@ -306,3 +306,35 @@ usually pre-fix bad samples. See the boundary note in
306
306
  ## License
307
307
 
308
308
  MIT © OfferKuai Team
309
+
310
+ ---
311
+
312
+ ## ⚠️ Upgrading to 0.3.12 — behaviour changes you must know
313
+
314
+ **1. New exit codes `3` / `4` — a defect fix that is also a breaking change.**
315
+ - `3` = **not assessable on this host**: the file's `header.version` is higher than the host supports
316
+ (e.g. checking a v3 log under `@deepseek-ai/dsh-session@0.1.0-rc.7`). Instead of reporting `broken`,
317
+ the tool now says the host lacks the capability, and `fix --apply` **refuses** the file (nothing written, no backup).
318
+ - `4` = **migration pre-check blocked**: the tool judges that the official upgrade chain will refuse the file.
319
+ - ⚠️ **Pipelines that treated `exit 0` as "fine" will now fail.** The old behaviour returned `0` even for files
320
+ the tool itself reported as refused by the official chain — a false negative where the exit code contradicted
321
+ the verdict. `--fail-on-migration` is an **explicit alias of the (now default) behaviour**; the **real opt-out
322
+ is `--no-fail-on-migration`**, which restores the 0.3.11 behaviour (structural green ⇒ `exit 0`). When both
323
+ flags are given, the opt-out wins. You can also ignore the exit code and read `assessmentScope` /
324
+ `migration.ready` from `--json` with your own threshold.
325
+
326
+ **2. `--json` gains `assessmentScope` (three values).**
327
+
328
+ | value | meaning |
329
+ |---|---|
330
+ | `full` | file is already the host's target format (no migration) — coverage is complete(已是当前格式、**无需迁移**)(已是当前格式、**无需迁移**) |
331
+ | `partial` | file needs migration ⇒ the migration pre-check covers **2 rules only; 6 classes are uncovered** (≠ pass; see `coverage.uncovered`) |
332
+ | `none` | **not assessable on this host** — no "usable / upgradable" claim is certified |
333
+
334
+ Under `partial` the CLI no longer prints an unconditional green: both the migration line and the last line say
335
+ the migration dimension is only partially covered.
336
+
337
+ **3. What "the health check passed" does *not* mean.**
338
+ The rule set is **not** the official upgrade chain's acceptance condition. `assessmentScope=partial` means
339
+ "the part this tool looked at is clean" — it does **not** mean the official upgrade will accept the file.
340
+ Read `coverage.uncovered` for the known gaps.
package/README.zh.md CHANGED
@@ -263,3 +263,31 @@ node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.z
263
263
  ## 许可
264
264
 
265
265
  MIT © OfferKuai Team
266
+
267
+ ---
268
+
269
+ ## ⚠️ 升级到 0.3.12 —— 必须知道的行为变更
270
+
271
+ **1. 新增退出码 `3` / `4`:本质是缺陷修复,形式上也是破坏性变更。**
272
+ - `3` = **不可在本宿主评估**:被检文件的 `header.version` 高于当前宿主支持的上限
273
+ (例如在 `@deepseek-ai/dsh-session@0.1.0-rc.7` 上检查 v3 日志)。此时**不再误报 `broken`**,
274
+ 而是明确说明宿主能力不足;`fix --apply` 会**拒绝**该文件(不落盘、不写备份)。
275
+ - `4` = **迁移预检 blocked**:本工具判断该文件会被官方升级链拒绝。
276
+ - ⚠️ **把 `exit 0` 当"过"的流水线会由过转挂。** 旧行为在**工具自己就报告"官方会拒"**的文件上仍返回 `0`
277
+ —— 退出码与结论矛盾的假阴性。`--fail-on-migration` 是**默认已生效行为的显式别名**;
278
+ **真正的 opt-out 是 `--no-fail-on-migration`**,它恢复 0.3.11 的行为(结构绿即 `exit 0`)。
279
+ 两者同时给时 **opt-out 优先**。也可改读 `--json` 的 `assessmentScope` / `migration.ready` 自定门槛。
280
+
281
+ **2. `--json` 新增 `assessmentScope`(三值)。**
282
+
283
+ | 值 | 含义 |
284
+ |---|---|
285
+ | `full` | 被检文件已是当前宿主的目标格式(无需迁移),覆盖完整(已是当前格式、**无需迁移**)(已是当前格式、**无需迁移**) |
286
+ | `partial` | 需要迁移 ⇒ 迁移预检**只覆盖 2 条规则,另有 6 类未覆盖**(≠ 通过;见 `coverage.uncovered`) |
287
+ | `none` | **不可在本宿主评估**(宿主能力不足),不认证任何"可用/可升级"结论 |
288
+
289
+ `partial` 时 CLI 不再给无条件绿:迁移行与末行都会写明"迁移维度仅部分覆盖"。
290
+
291
+ **3.「体检通过」到哪一步为止。**
292
+ 本工具的规则集**不等于**官方升级链的接受条件。`assessmentScope=partial` 只表示"本工具看过的那部分没问题",
293
+ **不构成**"官方升级会接受"。已知未覆盖的 6 类见 `coverage.uncovered`。
@@ -13,17 +13,56 @@
13
13
  * contracts 列出内置契约规则目录
14
14
  */
15
15
  import fs from 'node:fs';
16
- import { loadSessionLog, validateSessionLog, resumeVerdict, createPreWriter, repairSession, CONTRACT_RULES, ruleById, extractToolOutputs, auditToolCalls } from '../lib/index.js';
16
+ import { loadSessionLog, validateSessionLog, resumeVerdict, migrationVerdict, assessmentScope, createPreWriter, repairSession, readSessionHeader, CONTRACT_RULES, ruleById, extractToolOutputs, auditToolCalls, hostCapability, HOST_MAX_FILE_VERSION } from '../lib/index.js';
17
+
18
+ /**
19
+ * 同步写 fd(F4 修复)——`process.stdout.write` 在**管道**下是异步的,紧跟着的
20
+ * `process.exit()` 不会等待 flush:>~64KiB 的输出会丢尾,`--json` 因此变成非法 JSON
21
+ * (独立审核在 3000 事件夹具与真实 14.5MB v3 会话上复现,stdout 56648B 处 "Unterminated string")。
22
+ * 这里直接同步写 fd 1/2(非阻塞管道 EAGAIN 时自旋重试),做到"先写完再 exit"。
23
+ */
24
+ function writeAllSync(fd, text) {
25
+ const buf = Buffer.from(String(text), 'utf8');
26
+ let offset = 0;
27
+ while (offset < buf.length) {
28
+ try {
29
+ offset += fs.writeSync(fd, buf, offset, buf.length - offset);
30
+ } catch (err) {
31
+ if (err.code === 'EAGAIN') continue;
32
+ throw err;
33
+ }
34
+ }
35
+ }
36
+ const out = (text) => writeAllSync(1, text);
37
+ const errOut = (text) => writeAllSync(2, text);
17
38
 
18
39
  const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log contract guard)
19
40
 
20
41
  用法:
21
- dsh-log-contract check <session-log> [--json] [--max-details N] [--resume]
22
- 离线体检。session-log 支持 .jsonl 与 .jsonl.zstd。
23
- --json 输出机器可读 JSON 报告
42
+ dsh-log-contract check <session-log> [--json] [--max-details N] [--resume] [--fail-on-migration] [--no-fail-on-migration]
43
+ 离线体检 + **迁移预检**(两个**独立维度**,互不蕴含)。session-log 支持 .jsonl 与 .jsonl.zstd。
44
+ ⚠️ 口径边界:本工具只判定它实现的规则集。「ok」/「可加载」/「可继续」/「可压缩」
45
+ **不代表**会话"可用",**不代表**可安全编辑,也**不代表**官方升级路径会接受
46
+ (迁移是独立维度,覆盖边界见 contracts 输出与 lib/contracts.js 的 G 段)。
47
+ --json 输出机器可读 JSON 报告(含 host / assessable / assessmentScope / migration 字段)
24
48
  --max-details N 每条违规最多列 N 个缺失 seq(默认 8,--json 忽略)
25
- --resume 输出三档结论(L3):可加载 / 可继续 / 可压缩——
26
- 回答「这个会话还能不能用」;--json 时附带 verdict 字段
49
+ --resume 输出分层结论:可加载 / 可继续 / 可压缩(本工具规则集内)
50
+ + 迁移预检(官方 v0/v1/v2→当前格式会不会拒);--json 时附带。
51
+ --fail-on-migration migration.ready===false(工具判"官方升级会拒")时退出码 4。
52
+ **注意:默认已经是这个行为**(第四轮把"报告与退出码不一致"的假阴性修掉了);
53
+ 本开关是**显式别名**,便于 CI 把意图写进命令行。
54
+ --no-fail-on-migration **真 opt-out**:恢复 0.3.11 的行为——迁移 blocked 不再改退出码
55
+ (结构绿即 exit 0)。两者同时给时 **opt-out 优先**。若你的流水线依赖旧退出码,
56
+ 用本开关;也可改读 --json 的 assessmentScope / migration.ready 自定门槛。
57
+ 评估范围 assessmentScope(顶层字段,与 migration.assessmentScope 同值):
58
+ 'full' = 被检文件**已是当前官方格式(version=3)、无需迁移** ⇒ 工具规则集对该文件是完整的;
59
+ 'partial' = **需要迁移**(version<3):迁移维度只覆盖 2 条规则,另有 6 类未覆盖 ⇒
60
+ **不构成"官方升级会接受"**;'none' = 本宿主能力不足以评估(文件版本 > 宿主上限)。
61
+ 机器应判 assessmentScope == "full" 才可把绿读作完整评估(partial/none 都不该)。
62
+ 退出码:0 本工具认为可加载且无迁移阻断;1 有 error 级违规;3 不可在本宿主评估
63
+ (被检文件版本 > 宿主支持上限,如 rc.7 宿主的 v3 文件——**不是** broken);
64
+ 4 迁移预检 blocked(assessmentScope=partial 且 migration.ready=false;可用
65
+ --no-fail-on-migration 退出该档)。
27
66
 
28
67
  dsh-log-contract fix <session-log> [--remove-markers] [--neutralize] [--clip-crossstep] [--drop-failed-turns] [--trim-last N] [--compact-last N] [--tail-renumber D] [--neutralize-orphan] [--extract-turn N] [--keep-ranges a-b,c-d] [--apply] [--backup-dir DIR] [--json]
29
68
  诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
@@ -74,29 +113,73 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
74
113
  `;
75
114
 
76
115
  function fail(message, code = 1) {
77
- process.stderr.write(`${message}\n`);
116
+ errOut(`${message}\n`);
78
117
  process.exit(code);
79
118
  }
80
119
 
81
120
  function printViolations(violations, maxDetails = 8) {
82
121
  if (violations.length === 0) {
83
- process.stdout.write(' ✔ 无违规\n');
122
+ out(' ✔ 无违规\n');
84
123
  return;
85
124
  }
86
125
  for (const v of violations) {
87
126
  const loc = [v.seq !== null ? `seq ${v.seq}` : null, v.lineNo !== null ? `line ${v.lineNo}` : null]
88
127
  .filter(Boolean).join(' / ');
89
128
  const head = ` [${v.severity}] ${v.id} ${loc ? `@ ${loc}` : ''}${v.eventType ? ` (${v.eventType})` : ''}`;
90
- process.stdout.write(`${head}\n ${v.message}\n`);
129
+ out(`${head}\n ${v.message}\n`);
91
130
  if (Array.isArray(v.missingSeqs) && v.missingSeqs.length > maxDetails) {
92
- process.stdout.write(` …另有 ${v.missingSeqs.length - maxDetails} 个缺失 seq 未列出\n`);
131
+ out(` …另有 ${v.missingSeqs.length - maxDetails} 个缺失 seq 未列出\n`);
93
132
  }
94
133
  }
95
134
  }
96
135
 
136
+ /** S2 横幅:被检文件版本 > 宿主支持上限 ⇒ "不可在本宿主评估"(**不是** broken)。 */
137
+ function notAssessableBanner(result) {
138
+ const na = result.notAssessable ?? {};
139
+ return `\n⚠️ 不可在本宿主评估(not-assessable)\n`
140
+ + ` 被检文件 version=${na.fileVersion} > 本宿主支持的最大文件版本 ${na.hostMaxFileVersion}\n`
141
+ + ` 宿主 @deepseek-ai/dsh-session@${na.hostPackage}\n`
142
+ + ` 原因:${na.reason}\n`
143
+ + ` 已跳过规则:${(na.skippedRules ?? []).join(', ')}\n`
144
+ + ` 已执行:结构层(Z/H/R/E1·E2·E4·E5·E6/S9/M/P/P3·P4/I1);结构层结论 ${result.structuralOk ? '绿' : '有 error(见上)'}\n`
145
+ + ` ⚠️ 这不表示日志损坏,也**不要**据此运行 fix --apply —— 请用 0.1.5+ 宿主评估。\n`;
146
+ }
147
+
148
+ /** 迁移预检(独立维度)文本。
149
+ * @param {object} mig `migrationVerdict()` 结果
150
+ * @param {boolean} partial 待迁移文件(`migration.applies===true`)⇒ 迁移维度只部分覆盖,
151
+ * 不得给无条件绿(2026-09-14 最终复核规格 (i)+(iii))。
152
+ */
153
+ function migrationLine(mig, partial = false) {
154
+ if (!mig) return '';
155
+ if (mig.assessable === false) return ` 迁移预检:不可评估(本宿主能力不足)\n`;
156
+ if (mig.applies === false) return ` 迁移预检:— 不适用(${mig.reason ?? ''})\n`;
157
+ const n = mig.blocked?.length ?? 0;
158
+ let s = mig.ready
159
+ ? (partial
160
+ ? ` 迁移预检(独立维度,≠"可用/可升级"):⚠️ 仅覆盖 ${mig.coverage.implemented.length} 条规则;另有 ${mig.coverage.uncovered.length} 类未覆盖(未覆盖 ≠ 通过)—— **不构成"升级会接受"**\n`
161
+ : ` 迁移预检(独立维度,≠"可用/可升级"):✅ 通过(仅限本工具覆盖范围)\n`)
162
+ : ` 迁移预检(独立维度,≠"可用/可升级"):❌ blocked(${n} 项:官方升级路径会拒)\n`;
163
+ for (const v of (mig.blocked ?? []).slice(0, 5)) {
164
+ s += ` [${v.id}] seq ${v.seq ?? '-'}${v.eventType ? ` (${v.eventType})` : ''} — ${String(v.message).slice(0, 130)}\n`;
165
+ }
166
+ if (n > 5) s += ` …另有 ${n - 5} 项\n`;
167
+ s += ` 覆盖边界:已覆盖 ${mig.coverage.implemented.length} 条;未覆盖 ${mig.coverage.uncovered.length} 类官方迁移规则(未覆盖 ≠ 通过;判据 ${mig.coverage.note})\n`;
168
+ // 规格 (iii):把未覆盖类名直接列出来,人不必开 --json 才知道边界。
169
+ if (partial) {
170
+ for (const [i, c] of (mig.coverage.uncovered ?? []).entries()) s += ` ${i + 1}. ${c}\n`;
171
+ }
172
+ return s;
173
+ }
174
+
97
175
  function cmdCheck(args) {
98
176
  const json = args.includes('--json');
99
177
  const resume = args.includes('--resume');
178
+ // 第四轮/第五轮:`--fail-on-migration` 是**默认已生效行为**的显式别名;
179
+ // `--no-fail-on-migration` 是**真 opt-out**(恢复旧行为:迁移 blocked 不再改退出码)。
180
+ // 两者同时给 ⇒ opt-out 优先(help/README 写明)。
181
+ const failOnMigration = args.includes('--fail-on-migration');
182
+ const noFailOnMigration = args.includes('--no-fail-on-migration');
100
183
  const maxDetailsIdx = args.indexOf('--max-details');
101
184
  const maxDetails = maxDetailsIdx >= 0 && args[maxDetailsIdx + 1] ? Number(args[maxDetailsIdx + 1]) : 8;
102
185
  const file = args.find((a) => !a.startsWith('-'));
@@ -110,43 +193,76 @@ function cmdCheck(args) {
110
193
  }
111
194
  const result = validateSessionLog(log);
112
195
  const { summary, violations, ok } = result;
196
+ const notAssessable = result.assessable === false;
197
+ const migration = result.migration ?? migrationVerdict(result);
198
+ // 第四轮:机器可判的评估范围(partial = 待迁移文件,迁移维度只部分覆盖)。
199
+ const scope = result.assessmentScope ?? assessmentScope(result);
200
+ const partial = scope === 'partial';
201
+ const migBlocked = migration?.applies === true && migration.ready === false;
202
+ // 默认:`ready===false` ⇒ 非 0(第四轮修复的假阴性)。
203
+ // `--fail-on-migration` = 显式别名(同默认);`--no-fail-on-migration` = 真 opt-out(恢复旧行为)。
204
+ const migGate = !noFailOnMigration && (migBlocked || (failOnMigration && migration?.ready === false));
205
+ // 退出码:3 不可评估 > 1 结构 error > 4 迁移 blocked > 0。
206
+ const exitCode = notAssessable ? 3 : (!ok ? 1 : (migGate ? 4 : 0));
113
207
 
114
208
  if (json) {
115
- const payload = { file, ok, summary, violations };
209
+ const payload = { file, ok, assessable: !notAssessable, assessmentScope: scope, host: hostCapability(), migration, summary, violations };
210
+ if (notAssessable) payload.notAssessable = result.notAssessable;
116
211
  if (resume) payload.resume = resumeVerdict(result);
117
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
118
- process.exit(ok ? 0 : 1);
212
+ out(JSON.stringify(payload, null, 2) + '\n');
213
+ process.exit(exitCode);
214
+ }
215
+
216
+ if (notAssessable) {
217
+ out(notAssessableBanner(result));
218
+ printViolations(violations, maxDetails);
219
+ process.exit(3);
119
220
  }
120
221
 
121
222
  if (resume) {
122
223
  const v = resumeVerdict(result);
123
224
  const tier = v.verdict;
124
- const icons = { loadable: '✅ 可加载', resumable: '✅ 可继续', compactable: '✅ 可压缩', broken: '❌ 不可用' };
125
- process.stdout.write(`\n📋 dsh-log-contract check --resume —— ${file}\n`);
126
- process.stdout.write(` 事件 ${summary.events} surface 节点 ${summary.surfaceNodes} replace 代数 ${summary.replaceGeneration} ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
127
- process.stdout.write(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n\n`);
128
- process.stdout.write(` 三档结论:\n`);
225
+ // partial(待迁移文件)时不用 ✅:避免"全局绿"被读成"官方升级会接受"——用中性 ✔。
226
+ const passMark = partial ? '✔' : '✅';
227
+ const icons = { loadable: `${passMark} 可加载`, resumable: `${passMark} 可继续`, compactable: `${passMark} 可压缩`, broken: '❌ 不可用' };
228
+ out(`\n📋 dsh-log-contract check --resume —— ${file}\n`);
229
+ out(` 事件 ${summary.events} | surface 节点 ${summary.surfaceNodes} | replace 代数 ${summary.replaceGeneration} | 帧 ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB → ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
230
+ out(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n\n`);
231
+ out(` 分层结论(**本工具规则集内**,不代表"可用",也不代表官方升级会接受):\n`);
129
232
  const tiers = [
130
233
  ['可加载 loadable', v.loadable, '会话能被 DSH 读入(结构规则 S1-S9/E/W 全绿)', v.blocking.loadable],
131
234
  ['可继续 resumable', v.resumable, 'resume/followup 可用(结构 + I1 inbox 重放绿)', v.blocking.resumable],
132
235
  ['可压缩 compactable', v.compactable, '/compact 与压力测量可用(前两档 + T1/T2 token-meter 配对绿)', v.blocking.compactable],
133
236
  ];
134
237
  for (const [name, pass, desc, blockers] of tiers) {
135
- const mark = pass ? '✅' : '❌';
136
- process.stdout.write(` ${mark} ${name} — ${desc}\n`);
137
- if (blockers.length > 0) process.stdout.write(` 阻断: ${[...new Set(blockers)].join(', ')}\n`);
238
+ const mark = pass ? passMark : '❌';
239
+ out(` ${mark} ${name} — ${desc}\n`);
240
+ if (blockers.length > 0) out(` 阻断: ${[...new Set(blockers)].join(', ')}\n`);
138
241
  }
139
- process.stdout.write(`\n 结论: ${icons[tier]}${v.verdict === 'compactable' ? ' —— 可安全继续使用' : v.verdict === 'broken' ? ' —— 见上方违规明细(error 级 = 会话不可读/不可写)' : ' —— 部分能力受限'}\n\n`);
242
+ out(migrationLine(migration, partial));
243
+ out(`\n 结论: ${icons[tier]}${migBlocked
244
+ ? '(本工具规则集内)—— 但**迁移预检 blocked**:不得据此宣称"可升级"'
245
+ : partial
246
+ ? '(本工具规则集内)—— 但**迁移维度仅部分覆盖**(assessmentScope=partial):不得据此宣称"可升级"'
247
+ : v.verdict === 'compactable' ? ' —— 本工具规则集内可安全继续使用' : v.verdict === 'broken' ? ' —— 见上方违规明细(error 级 = 会话不可读/不可写)' : ' —— 部分能力受限'}\n\n`);
140
248
  printViolations(violations, maxDetails);
141
- process.exit(ok ? 0 : 1);
249
+ process.exit(exitCode);
142
250
  }
143
251
 
144
- process.stdout.write(`\n📋 dsh-log-contract check —— ${file}\n`);
145
- process.stdout.write(` 事件 ${summary.events} | surface 节点 ${summary.surfaceNodes} | replace 代数 ${summary.replaceGeneration} | 帧 ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB → ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
146
- process.stdout.write(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n\n`);
252
+ out(`\n📋 dsh-log-contract check —— ${file}\n`);
253
+ out(` 事件 ${summary.events} | surface 节点 ${summary.surfaceNodes} | replace 代数 ${summary.replaceGeneration} | 帧 ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB → ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
254
+ out(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n`);
255
+ out(migrationLine(migration, partial));
256
+ out('\n');
147
257
  printViolations(violations, maxDetails);
148
- process.stdout.write(`\n${ok ? '✅ 通过:官方 foldSurface 可重放,三层契约绿' : '❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)'}\n\n`);
149
- process.exit(ok ? 0 : 1);
258
+ out(`\n${ok
259
+ ? (migBlocked
260
+ ? '⚠️ 本工具认为可加载(结构层绿),但**迁移预检 blocked** —— 不得据此宣称"可用/可升级";详见上方迁移预检'
261
+ : partial
262
+ ? '⚠️ 本工具规则集内未发现违规;**迁移维度仅部分覆盖**(assessmentScope=partial,不代表官方升级会接受)—— 详见上方迁移预检的覆盖边界'
263
+ : '✅ 通过(本工具规则集内:结构层绿)')
264
+ : '❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)'}\n\n`);
265
+ process.exit(exitCode);
150
266
  }
151
267
 
152
268
  function cmdPrewrite(args) {
@@ -170,12 +286,17 @@ function cmdPrewrite(args) {
170
286
  fail(`会话日志读取失败:${err.message}`);
171
287
  }
172
288
  const baseline = validateSessionLog(log);
289
+ // S2:本宿主评估不了的文件**不能**给出写前结论(否则会拿假阳性去拦合法写入)
290
+ if (baseline.assessable === false) {
291
+ fail(`不可在本宿主评估该会话(${baseline.notAssessable?.reason ?? '宿主能力不足'})——写前校验无法给出可信结论;请用 0.1.5+ 宿主`, 3);
292
+ }
173
293
  if (!baseline.ok) {
174
294
  // 基线已坏:写前校验无法在坏基线上给出可信结论
175
295
  fail(`基线会话已有 ${baseline.summary.bySeverity.error} 个 error 级违规,请先修复基线再校验写入(安全修复协议第 2 步:改前基线必须绿)`);
176
296
  }
177
297
 
178
- const prewriter = createPreWriter({ events: log.events.map((e) => e.event) });
298
+ // C2:把被检文件的 header 显式交给 prewriter(版本不再靠模块级全局;缺省也能自行推断)
299
+ const prewriter = createPreWriter({ events: log.events.map((e) => e.event), header: log.header });
179
300
  let result;
180
301
  if (Object.hasOwn(plan, 'append')) {
181
302
  result = prewriter.validateAppend(plan.append);
@@ -189,26 +310,26 @@ function cmdPrewrite(args) {
189
310
  }
190
311
 
191
312
  if (json) {
192
- process.stdout.write(JSON.stringify({ file, op: result.op, ok: result.ok, bySeverity: result.bySeverity, violations: result.violations }, null, 2) + '\n');
313
+ out(JSON.stringify({ file, op: result.op, ok: result.ok, bySeverity: result.bySeverity, violations: result.violations }, null, 2) + '\n');
193
314
  process.exit(result.ok ? 0 : 1);
194
315
  }
195
316
 
196
- process.stdout.write(`\n✍️ dsh-log-contract prewrite —— ${file}(op: ${result.op},nextSeq: ${prewriter.nextSeq})\n\n`);
317
+ out(`\n✍️ dsh-log-contract prewrite —— ${file}(op: ${result.op},nextSeq: ${prewriter.nextSeq})\n\n`);
197
318
  if (result.ok) {
198
- process.stdout.write(' ✅ 写入安全:三层契约全绿(持久化 foldSurface 可重放 / 引擎层无崩溃风险 / 插件 marker 语义自洽)\n');
199
- process.stdout.write(` 写入后 surface 节点 ${result.stateAfter.surfaceNodes} 个,nextSeq ${result.stateAfter.nextSeq}\n\n`);
319
+ out(' ✅ 写入安全:三层契约全绿(持久化 foldSurface 可重放 / 引擎层无崩溃风险 / 插件 marker 语义自洽)\n');
320
+ out(` 写入后 surface 节点 ${result.stateAfter.surfaceNodes} 个,nextSeq ${result.stateAfter.nextSeq}\n\n`);
200
321
  process.exit(0);
201
322
  }
202
- process.stdout.write(' ❌ 写入会被拒:\n');
323
+ out(' ❌ 写入会被拒:\n');
203
324
  printViolations(result.violations);
204
- process.stdout.write('\n');
325
+ out('\n');
205
326
  process.exit(1);
206
327
  }
207
328
 
208
329
  function cmdContracts() {
209
- process.stdout.write('dsh-log-contract 契约规则目录(spec:59 条审计发现 + 官方源码逐行核对)\n\n');
330
+ out('dsh-log-contract 契约规则目录(spec:59 条审计发现 + 官方源码逐行核对)\n\n');
210
331
  for (const r of CONTRACT_RULES) {
211
- process.stdout.write(` ${r.id} [${r.severity}/${r.layer}] ${r.title}\n ${r.description}\n 出处: ${r.source}\n\n`);
332
+ out(` ${r.id} [${r.severity}/${r.layer}] ${r.title}\n ${r.description}\n 出处: ${r.source}\n\n`);
212
333
  }
213
334
  }
214
335
 
@@ -240,26 +361,35 @@ function cmdFix(args) {
240
361
  const file = args.find((a) => !a.startsWith('-'));
241
362
  if (!file) fail(USAGE);
242
363
 
364
+ // S2 安全闸:本宿主评估不了的文件**不许**修。危害路径(独立复核指出):rc.7 上把健康 v3
365
+ // 日志报成 broken → 用户以为日志坏了去跑 `fix --apply` ⇒ 在健康日志上动手。这里直接拒绝。
366
+ const head = readSessionHeader(file);
367
+ const fileVersion = Number.isSafeInteger(head?.version) ? head.version : 0;
368
+ if (fileVersion > HOST_MAX_FILE_VERSION) {
369
+ fail(`拒绝修复:被检文件 version=${fileVersion} 高于本宿主 @deepseek-ai/dsh-session@${hostCapability().hostPackage} 支持的最大版本 ${HOST_MAX_FILE_VERSION}`
370
+ + ` —— 本宿主评估不了该文件(不是"日志坏了")。请用 0.1.5+ 宿主修复。`, 3);
371
+ }
372
+
243
373
  const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, trimBudget, compactLast, tailRenumberDelta, neutralizeOrphan, extractTurn, extractTurnTo, keepRanges, apply, backupDir });
244
374
  if (json) {
245
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
375
+ out(JSON.stringify(result, null, 2) + '\n');
246
376
  process.exit(result.ok ? 0 : 1);
247
377
  }
248
378
 
249
- process.stdout.write(`\n🔧 dsh-log-contract fix —— ${file}\n`);
250
- process.stdout.write(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
379
+ out(`\n🔧 dsh-log-contract fix —— ${file}\n`);
380
+ out(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
251
381
  if (result.applied) {
252
- process.stdout.write(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
253
- process.stdout.write(` 备份:${result.backupPath}\n`);
254
- process.stdout.write(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
382
+ out(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
383
+ out(` 备份:${result.backupPath}\n`);
384
+ out(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
255
385
  } else if (apply && !result.ok) {
256
- process.stdout.write(' ❌ 存在 error 级问题,拒绝应用(改前基线必须绿;先修基线或检查输出)\n');
386
+ out(' ❌ 存在 error 级问题,拒绝应用(改前基线必须绿;先修基线或检查输出)\n');
257
387
  } else if (apply) {
258
- process.stdout.write(' (--apply 且无问题——无内容可修)\n');
388
+ out(' (--apply 且无问题——无内容可修)\n');
259
389
  } else {
260
- process.stdout.write(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker 可中和、${result.clipped} 个跨 step 引用可裁剪;加 --apply 落盘,--remove-markers / --neutralize / --clip-crossstep / --drop-failed-turns / --trim-last N / --trim-budget N / --tail-renumber D / --neutralize-orphan / --extract-turn N / --keep-ranges a-b,c-d 启用于对应修复)\n`);
390
+ out(` (干跑模式:${result.removed} 项可移除、${result.renumbered} 行待重编号、${result.neutralized} 个 turn-null marker 可中和、${result.clipped} 个跨 step 引用可裁剪;加 --apply 落盘,--remove-markers / --neutralize / --clip-crossstep / --drop-failed-turns / --trim-last N / --trim-budget N / --tail-renumber D / --neutralize-orphan / --extract-turn N / --keep-ranges a-b,c-d 启用于对应修复)\n`);
261
391
  }
262
- process.stdout.write('\n');
392
+ out('\n');
263
393
  process.exit(result.ok ? 0 : 1);
264
394
  }
265
395
 
@@ -277,11 +407,11 @@ function cmdExtract(args) {
277
407
  const log = loadSessionLog(file);
278
408
  const { pairs, total } = extractToolOutputs(log.events.map((e) => e.event), pattern, { minSize });
279
409
  if (json) {
280
- process.stdout.write(JSON.stringify({ file, pattern, matched: pairs.length, total, pairs: pairs.map((p) => ({ callId: p.callId, command: p.command, size: p.size })) }, null, 2) + '\n');
410
+ out(JSON.stringify({ file, pattern, matched: pairs.length, total, pairs: pairs.map((p) => ({ callId: p.callId, command: p.command, size: p.size })) }, null, 2) + '\n');
281
411
  process.exit(0);
282
412
  }
283
- process.stdout.write(`\n🔍 dsh-log-contract extract —— ${file}\n`);
284
- process.stdout.write(` 命令正则:/${pattern}/ | 匹配 ${pairs.length} 个输出(共 ${total} 个工具调用,min-size ${minSize})\n`);
413
+ out(`\n🔍 dsh-log-contract extract —— ${file}\n`);
414
+ out(` 命令正则:/${pattern}/ | 匹配 ${pairs.length} 个输出(共 ${total} 个工具调用,min-size ${minSize})\n`);
285
415
  if (outDir) {
286
416
  fs.mkdirSync(outDir, { recursive: true });
287
417
  let written = 0;
@@ -290,14 +420,14 @@ function cmdExtract(args) {
290
420
  fs.writeFileSync(`${outDir}/${safe}.txt`, p.text);
291
421
  written += 1;
292
422
  }
293
- process.stdout.write(` 已导出 ${written} 个输出到 ${outDir}\n`);
423
+ out(` 已导出 ${written} 个输出到 ${outDir}\n`);
294
424
  } else {
295
425
  for (const p of pairs.slice(0, 3)) {
296
- process.stdout.write(` - [${p.size}B] ${p.command.slice(0, 60)}… ${p.text.slice(0, 80).replace(/\n/g, ' ')}…\n`);
426
+ out(` - [${p.size}B] ${p.command.slice(0, 60)}… ${p.text.slice(0, 80).replace(/\n/g, ' ')}…\n`);
297
427
  }
298
- if (pairs.length > 3) process.stdout.write(` … 其余 ${pairs.length - 3} 个(加 --out DIR 全部导出)\n`);
428
+ if (pairs.length > 3) out(` … 其余 ${pairs.length - 3} 个(加 --out DIR 全部导出)\n`);
299
429
  }
300
- process.stdout.write('\n');
430
+ out('\n');
301
431
  process.exit(0);
302
432
  }
303
433
 
@@ -308,30 +438,30 @@ function cmdAuditReport(args) {
308
438
  const log = loadSessionLog(file);
309
439
  const report = auditToolCalls(log.events.map((e) => e.event));
310
440
  if (json) {
311
- process.stdout.write(JSON.stringify({ file, ...report }, null, 2) + '\n');
441
+ out(JSON.stringify({ file, ...report }, null, 2) + '\n');
312
442
  process.exit(0);
313
443
  }
314
- process.stdout.write(`\n📊 dsh-log-contract audit-report —— ${file}\n`);
315
- process.stdout.write(` 工具调用 ${report.calls} | 结果 ${report.results} | 孤儿 ${report.orphans} | 配对率 ${(report.pairingRate * 100).toFixed(1)}%\n`);
316
- process.stdout.write(` 输出总字节 ${report.outputBytes}`);
317
- if (report.largest) process.stdout.write(` | 最大 ${report.largest.size}B(${(report.largest.command || '?').slice(0, 40)})`);
318
- process.stdout.write(`\n 命令分布(前 ${report.commands.top.length} 个去重):\n`);
444
+ out(`\n📊 dsh-log-contract audit-report —— ${file}\n`);
445
+ out(` 工具调用 ${report.calls} | 结果 ${report.results} | 孤儿 ${report.orphans} | 配对率 ${(report.pairingRate * 100).toFixed(1)}%\n`);
446
+ out(` 输出总字节 ${report.outputBytes}`);
447
+ if (report.largest) out(` | 最大 ${report.largest.size}B(${(report.largest.command || '?').slice(0, 40)})`);
448
+ out(`\n 命令分布(前 ${report.commands.top.length} 个去重):\n`);
319
449
  for (const { command, count } of report.commands.top.slice(0, 8)) {
320
- process.stdout.write(` ${String(count).padStart(4)} ${(command || '(no-command)').slice(0, 70)}\n`);
450
+ out(` ${String(count).padStart(4)} ${(command || '(no-command)').slice(0, 70)}\n`);
321
451
  }
322
- process.stdout.write('\n');
452
+ out('\n');
323
453
  process.exit(0);
324
454
  }
325
455
 
326
456
  const args = process.argv.slice(2);
327
457
  const cmd = args[0];
328
458
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
329
- process.stdout.write(USAGE);
459
+ out(USAGE);
330
460
  process.exit(0);
331
461
  }
332
462
  if (cmd === '--version' || cmd === '-v') {
333
463
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
334
- process.stdout.write(`dsh-log-contract ${pkg.version}\n`);
464
+ out(`dsh-log-contract ${pkg.version}\n`);
335
465
  process.exit(0);
336
466
  }
337
467
  if (cmd === 'check') cmdCheck(args.slice(1));