dsh-log-contract 0.3.11 → 0.3.12

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,34 @@ 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**, not a toggle, and
322
+ there is **no opt-out**; for the old behaviour read `assessmentScope` / `migration.ready` from `--json` and
323
+ apply your own threshold.
324
+
325
+ **2. `--json` gains `assessmentScope` (three values).**
326
+
327
+ | value | meaning |
328
+ |---|---|
329
+ | `full` | file is already the host's target format (no migration) — coverage is complete |
330
+ | `partial` | file needs migration ⇒ the migration pre-check covers **2 rules only; 6 classes are uncovered** (≠ pass; see `coverage.uncovered`) |
331
+ | `none` | **not assessable on this host** — no "usable / upgradable" claim is certified |
332
+
333
+ Under `partial` the CLI no longer prints an unconditional green: both the migration line and the last line say
334
+ the migration dimension is only partially covered.
335
+
336
+ **3. What "the health check passed" does *not* mean.**
337
+ The rule set is **not** the official upgrade chain's acceptance condition. `assessmentScope=partial` means
338
+ "the part this tool looked at is clean" — it does **not** mean the official upgrade will accept the file.
339
+ Read `coverage.uncovered` for the known gaps.
package/README.zh.md CHANGED
@@ -263,3 +263,30 @@ 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**;需要旧行为请读 `--json` 的 `assessmentScope` / `migration.ready` 自定门槛。
279
+
280
+ **2. `--json` 新增 `assessmentScope`(三值)。**
281
+
282
+ | 值 | 含义 |
283
+ |---|---|
284
+ | `full` | 被检文件已是当前宿主的目标格式(无需迁移),覆盖完整 |
285
+ | `partial` | 需要迁移 ⇒ 迁移预检**只覆盖 2 条规则,另有 6 类未覆盖**(≠ 通过;见 `coverage.uncovered`) |
286
+ | `none` | **不可在本宿主评估**(宿主能力不足),不认证任何"可用/可升级"结论 |
287
+
288
+ `partial` 时 CLI 不再给无条件绿:迁移行与末行都会写明"迁移维度仅部分覆盖"。
289
+
290
+ **3.「体检通过」到哪一步为止。**
291
+ 本工具的规则集**不等于**官方升级链的接受条件。`assessmentScope=partial` 只表示"本工具看过的那部分没问题",
292
+ **不构成**"官方升级会接受"。已知未覆盖的 6 类见 `coverage.uncovered`。
@@ -13,17 +13,50 @@
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]
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
+ **注意:现行默认行为已经是"ready===false ⇒ 退出码 4"**;本开关是显式别名,
53
+ 便于 CI 把意图写进命令行(也与将来若放宽默认值时保留 opt-in 口径)。
54
+ 评估范围 assessmentScope(顶层字段):'partial' = 待迁移文件(迁移维度只覆盖 2 条规则,
55
+ 另有 6 类未覆盖)⇒ **不构成"官方升级会接受"**;'full' = 无需迁移(已是当前格式);
56
+ 'none' = 本宿主能力不足以评估。机器应判 assessmentScope == "full" 才可把绿读作完整评估。
57
+ 退出码:0 本工具认为可加载且无迁移阻断;1 有 error 级违规;3 不可在本宿主评估
58
+ (被检文件版本 > 宿主支持上限,如 rc.7 宿主的 v3 文件——**不是** broken);
59
+ 4 迁移预检 blocked(assessmentScope=partial 且 migration.ready=false)。
27
60
 
28
61
  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
62
  诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
@@ -74,29 +107,71 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
74
107
  `;
75
108
 
76
109
  function fail(message, code = 1) {
77
- process.stderr.write(`${message}\n`);
110
+ errOut(`${message}\n`);
78
111
  process.exit(code);
79
112
  }
80
113
 
81
114
  function printViolations(violations, maxDetails = 8) {
82
115
  if (violations.length === 0) {
83
- process.stdout.write(' ✔ 无违规\n');
116
+ out(' ✔ 无违规\n');
84
117
  return;
85
118
  }
86
119
  for (const v of violations) {
87
120
  const loc = [v.seq !== null ? `seq ${v.seq}` : null, v.lineNo !== null ? `line ${v.lineNo}` : null]
88
121
  .filter(Boolean).join(' / ');
89
122
  const head = ` [${v.severity}] ${v.id} ${loc ? `@ ${loc}` : ''}${v.eventType ? ` (${v.eventType})` : ''}`;
90
- process.stdout.write(`${head}\n ${v.message}\n`);
123
+ out(`${head}\n ${v.message}\n`);
91
124
  if (Array.isArray(v.missingSeqs) && v.missingSeqs.length > maxDetails) {
92
- process.stdout.write(` …另有 ${v.missingSeqs.length - maxDetails} 个缺失 seq 未列出\n`);
125
+ out(` …另有 ${v.missingSeqs.length - maxDetails} 个缺失 seq 未列出\n`);
93
126
  }
94
127
  }
95
128
  }
96
129
 
130
+ /** S2 横幅:被检文件版本 > 宿主支持上限 ⇒ "不可在本宿主评估"(**不是** broken)。 */
131
+ function notAssessableBanner(result) {
132
+ const na = result.notAssessable ?? {};
133
+ return `\n⚠️ 不可在本宿主评估(not-assessable)\n`
134
+ + ` 被检文件 version=${na.fileVersion} > 本宿主支持的最大文件版本 ${na.hostMaxFileVersion}\n`
135
+ + ` 宿主 @deepseek-ai/dsh-session@${na.hostPackage}\n`
136
+ + ` 原因:${na.reason}\n`
137
+ + ` 已跳过规则:${(na.skippedRules ?? []).join(', ')}\n`
138
+ + ` 已执行:结构层(Z/H/R/E1·E2·E4·E5·E6/S9/M/P/P3·P4/I1);结构层结论 ${result.structuralOk ? '绿' : '有 error(见上)'}\n`
139
+ + ` ⚠️ 这不表示日志损坏,也**不要**据此运行 fix --apply —— 请用 0.1.5+ 宿主评估。\n`;
140
+ }
141
+
142
+ /** 迁移预检(独立维度)文本。
143
+ * @param {object} mig `migrationVerdict()` 结果
144
+ * @param {boolean} partial 待迁移文件(`migration.applies===true`)⇒ 迁移维度只部分覆盖,
145
+ * 不得给无条件绿(2026-09-14 最终复核规格 (i)+(iii))。
146
+ */
147
+ function migrationLine(mig, partial = false) {
148
+ if (!mig) return '';
149
+ if (mig.assessable === false) return ` 迁移预检:不可评估(本宿主能力不足)\n`;
150
+ if (mig.applies === false) return ` 迁移预检:— 不适用(${mig.reason ?? ''})\n`;
151
+ const n = mig.blocked?.length ?? 0;
152
+ let s = mig.ready
153
+ ? (partial
154
+ ? ` 迁移预检(独立维度,≠"可用/可升级"):⚠️ 仅覆盖 ${mig.coverage.implemented.length} 条规则;另有 ${mig.coverage.uncovered.length} 类未覆盖(未覆盖 ≠ 通过)—— **不构成"升级会接受"**\n`
155
+ : ` 迁移预检(独立维度,≠"可用/可升级"):✅ 通过(仅限本工具覆盖范围)\n`)
156
+ : ` 迁移预检(独立维度,≠"可用/可升级"):❌ blocked(${n} 项:官方升级路径会拒)\n`;
157
+ for (const v of (mig.blocked ?? []).slice(0, 5)) {
158
+ s += ` [${v.id}] seq ${v.seq ?? '-'}${v.eventType ? ` (${v.eventType})` : ''} — ${String(v.message).slice(0, 130)}\n`;
159
+ }
160
+ if (n > 5) s += ` …另有 ${n - 5} 项\n`;
161
+ s += ` 覆盖边界:已覆盖 ${mig.coverage.implemented.length} 条;未覆盖 ${mig.coverage.uncovered.length} 类官方迁移规则(未覆盖 ≠ 通过;判据 ${mig.coverage.note})\n`;
162
+ // 规格 (iii):把未覆盖类名直接列出来,人不必开 --json 才知道边界。
163
+ if (partial) {
164
+ for (const [i, c] of (mig.coverage.uncovered ?? []).entries()) s += ` ${i + 1}. ${c}\n`;
165
+ }
166
+ return s;
167
+ }
168
+
97
169
  function cmdCheck(args) {
98
170
  const json = args.includes('--json');
99
171
  const resume = args.includes('--resume');
172
+ // 第四轮(最终复核 §六-2):显式开关。现行默认行为已是"ready===false ⇒ exit 4",
173
+ // 本开关是显式别名,便于 CI 把意图写进命令行。
174
+ const failOnMigration = args.includes('--fail-on-migration');
100
175
  const maxDetailsIdx = args.indexOf('--max-details');
101
176
  const maxDetails = maxDetailsIdx >= 0 && args[maxDetailsIdx + 1] ? Number(args[maxDetailsIdx + 1]) : 8;
102
177
  const file = args.find((a) => !a.startsWith('-'));
@@ -110,43 +185,75 @@ function cmdCheck(args) {
110
185
  }
111
186
  const result = validateSessionLog(log);
112
187
  const { summary, violations, ok } = result;
188
+ const notAssessable = result.assessable === false;
189
+ const migration = result.migration ?? migrationVerdict(result);
190
+ // 第四轮:机器可判的评估范围(partial = 待迁移文件,迁移维度只部分覆盖)。
191
+ const scope = result.assessmentScope ?? assessmentScope(result);
192
+ const partial = scope === 'partial';
193
+ const migBlocked = migration?.applies === true && migration.ready === false;
194
+ // `--fail-on-migration` 与默认同效(显式别名):`ready===false` ⇒ 非 0。
195
+ const migGate = migBlocked || (failOnMigration && migration?.ready === false);
196
+ // 退出码:3 不可评估 > 1 结构 error > 4 迁移 blocked > 0。
197
+ const exitCode = notAssessable ? 3 : (!ok ? 1 : (migGate ? 4 : 0));
113
198
 
114
199
  if (json) {
115
- const payload = { file, ok, summary, violations };
200
+ const payload = { file, ok, assessable: !notAssessable, assessmentScope: scope, host: hostCapability(), migration, summary, violations };
201
+ if (notAssessable) payload.notAssessable = result.notAssessable;
116
202
  if (resume) payload.resume = resumeVerdict(result);
117
- process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
118
- process.exit(ok ? 0 : 1);
203
+ out(JSON.stringify(payload, null, 2) + '\n');
204
+ process.exit(exitCode);
205
+ }
206
+
207
+ if (notAssessable) {
208
+ out(notAssessableBanner(result));
209
+ printViolations(violations, maxDetails);
210
+ process.exit(3);
119
211
  }
120
212
 
121
213
  if (resume) {
122
214
  const v = resumeVerdict(result);
123
215
  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`);
216
+ // partial(待迁移文件)时不用 ✅:避免"全局绿"被读成"官方升级会接受"——用中性 ✔。
217
+ const passMark = partial ? '✔' : '✅';
218
+ const icons = { loadable: `${passMark} 可加载`, resumable: `${passMark} 可继续`, compactable: `${passMark} 可压缩`, broken: '❌ 不可用' };
219
+ out(`\n📋 dsh-log-contract check --resume —— ${file}\n`);
220
+ out(` 事件 ${summary.events} | surface 节点 ${summary.surfaceNodes} | replace 代数 ${summary.replaceGeneration} | 帧 ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB → ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
221
+ out(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n\n`);
222
+ out(` 分层结论(**本工具规则集内**,不代表"可用",也不代表官方升级会接受):\n`);
129
223
  const tiers = [
130
224
  ['可加载 loadable', v.loadable, '会话能被 DSH 读入(结构规则 S1-S9/E/W 全绿)', v.blocking.loadable],
131
225
  ['可继续 resumable', v.resumable, 'resume/followup 可用(结构 + I1 inbox 重放绿)', v.blocking.resumable],
132
226
  ['可压缩 compactable', v.compactable, '/compact 与压力测量可用(前两档 + T1/T2 token-meter 配对绿)', v.blocking.compactable],
133
227
  ];
134
228
  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`);
229
+ const mark = pass ? passMark : '❌';
230
+ out(` ${mark} ${name} — ${desc}\n`);
231
+ if (blockers.length > 0) out(` 阻断: ${[...new Set(blockers)].join(', ')}\n`);
138
232
  }
139
- process.stdout.write(`\n 结论: ${icons[tier]}${v.verdict === 'compactable' ? ' —— 可安全继续使用' : v.verdict === 'broken' ? ' —— 见上方违规明细(error 级 = 会话不可读/不可写)' : ' —— 部分能力受限'}\n\n`);
233
+ out(migrationLine(migration, partial));
234
+ out(`\n 结论: ${icons[tier]}${migBlocked
235
+ ? '(本工具规则集内)—— 但**迁移预检 blocked**:不得据此宣称"可升级"'
236
+ : partial
237
+ ? '(本工具规则集内)—— 但**迁移维度仅部分覆盖**(assessmentScope=partial):不得据此宣称"可升级"'
238
+ : v.verdict === 'compactable' ? ' —— 本工具规则集内可安全继续使用' : v.verdict === 'broken' ? ' —— 见上方违规明细(error 级 = 会话不可读/不可写)' : ' —— 部分能力受限'}\n\n`);
140
239
  printViolations(violations, maxDetails);
141
- process.exit(ok ? 0 : 1);
240
+ process.exit(exitCode);
142
241
  }
143
242
 
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`);
243
+ out(`\n📋 dsh-log-contract check —— ${file}\n`);
244
+ out(` 事件 ${summary.events} | surface 节点 ${summary.surfaceNodes} | replace 代数 ${summary.replaceGeneration} | 帧 ${summary.frames}(${(summary.compressedBytes / 1024).toFixed(1)}KiB → ${(summary.plaintextBytes / 1024).toFixed(1)}KiB)\n`);
245
+ out(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n`);
246
+ out(migrationLine(migration, partial));
247
+ out('\n');
147
248
  printViolations(violations, maxDetails);
148
- process.stdout.write(`\n${ok ? '✅ 通过:官方 foldSurface 可重放,三层契约绿' : '❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)'}\n\n`);
149
- process.exit(ok ? 0 : 1);
249
+ out(`\n${ok
250
+ ? (migBlocked
251
+ ? '⚠️ 本工具认为可加载(结构层绿),但**迁移预检 blocked** —— 不得据此宣称"可用/可升级";详见上方迁移预检'
252
+ : partial
253
+ ? '⚠️ 本工具规则集内未发现违规;**迁移维度仅部分覆盖**(assessmentScope=partial,不代表官方升级会接受)—— 详见上方迁移预检的覆盖边界'
254
+ : '✅ 通过(本工具规则集内:结构层绿)')
255
+ : '❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)'}\n\n`);
256
+ process.exit(exitCode);
150
257
  }
151
258
 
152
259
  function cmdPrewrite(args) {
@@ -170,12 +277,17 @@ function cmdPrewrite(args) {
170
277
  fail(`会话日志读取失败:${err.message}`);
171
278
  }
172
279
  const baseline = validateSessionLog(log);
280
+ // S2:本宿主评估不了的文件**不能**给出写前结论(否则会拿假阳性去拦合法写入)
281
+ if (baseline.assessable === false) {
282
+ fail(`不可在本宿主评估该会话(${baseline.notAssessable?.reason ?? '宿主能力不足'})——写前校验无法给出可信结论;请用 0.1.5+ 宿主`, 3);
283
+ }
173
284
  if (!baseline.ok) {
174
285
  // 基线已坏:写前校验无法在坏基线上给出可信结论
175
286
  fail(`基线会话已有 ${baseline.summary.bySeverity.error} 个 error 级违规,请先修复基线再校验写入(安全修复协议第 2 步:改前基线必须绿)`);
176
287
  }
177
288
 
178
- const prewriter = createPreWriter({ events: log.events.map((e) => e.event) });
289
+ // C2:把被检文件的 header 显式交给 prewriter(版本不再靠模块级全局;缺省也能自行推断)
290
+ const prewriter = createPreWriter({ events: log.events.map((e) => e.event), header: log.header });
179
291
  let result;
180
292
  if (Object.hasOwn(plan, 'append')) {
181
293
  result = prewriter.validateAppend(plan.append);
@@ -189,26 +301,26 @@ function cmdPrewrite(args) {
189
301
  }
190
302
 
191
303
  if (json) {
192
- process.stdout.write(JSON.stringify({ file, op: result.op, ok: result.ok, bySeverity: result.bySeverity, violations: result.violations }, null, 2) + '\n');
304
+ out(JSON.stringify({ file, op: result.op, ok: result.ok, bySeverity: result.bySeverity, violations: result.violations }, null, 2) + '\n');
193
305
  process.exit(result.ok ? 0 : 1);
194
306
  }
195
307
 
196
- process.stdout.write(`\n✍️ dsh-log-contract prewrite —— ${file}(op: ${result.op},nextSeq: ${prewriter.nextSeq})\n\n`);
308
+ out(`\n✍️ dsh-log-contract prewrite —— ${file}(op: ${result.op},nextSeq: ${prewriter.nextSeq})\n\n`);
197
309
  if (result.ok) {
198
- process.stdout.write(' ✅ 写入安全:三层契约全绿(持久化 foldSurface 可重放 / 引擎层无崩溃风险 / 插件 marker 语义自洽)\n');
199
- process.stdout.write(` 写入后 surface 节点 ${result.stateAfter.surfaceNodes} 个,nextSeq ${result.stateAfter.nextSeq}\n\n`);
310
+ out(' ✅ 写入安全:三层契约全绿(持久化 foldSurface 可重放 / 引擎层无崩溃风险 / 插件 marker 语义自洽)\n');
311
+ out(` 写入后 surface 节点 ${result.stateAfter.surfaceNodes} 个,nextSeq ${result.stateAfter.nextSeq}\n\n`);
200
312
  process.exit(0);
201
313
  }
202
- process.stdout.write(' ❌ 写入会被拒:\n');
314
+ out(' ❌ 写入会被拒:\n');
203
315
  printViolations(result.violations);
204
- process.stdout.write('\n');
316
+ out('\n');
205
317
  process.exit(1);
206
318
  }
207
319
 
208
320
  function cmdContracts() {
209
- process.stdout.write('dsh-log-contract 契约规则目录(spec:59 条审计发现 + 官方源码逐行核对)\n\n');
321
+ out('dsh-log-contract 契约规则目录(spec:59 条审计发现 + 官方源码逐行核对)\n\n');
210
322
  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`);
323
+ out(` ${r.id} [${r.severity}/${r.layer}] ${r.title}\n ${r.description}\n 出处: ${r.source}\n\n`);
212
324
  }
213
325
  }
214
326
 
@@ -240,26 +352,35 @@ function cmdFix(args) {
240
352
  const file = args.find((a) => !a.startsWith('-'));
241
353
  if (!file) fail(USAGE);
242
354
 
355
+ // S2 安全闸:本宿主评估不了的文件**不许**修。危害路径(独立复核指出):rc.7 上把健康 v3
356
+ // 日志报成 broken → 用户以为日志坏了去跑 `fix --apply` ⇒ 在健康日志上动手。这里直接拒绝。
357
+ const head = readSessionHeader(file);
358
+ const fileVersion = Number.isSafeInteger(head?.version) ? head.version : 0;
359
+ if (fileVersion > HOST_MAX_FILE_VERSION) {
360
+ fail(`拒绝修复:被检文件 version=${fileVersion} 高于本宿主 @deepseek-ai/dsh-session@${hostCapability().hostPackage} 支持的最大版本 ${HOST_MAX_FILE_VERSION}`
361
+ + ` —— 本宿主评估不了该文件(不是"日志坏了")。请用 0.1.5+ 宿主修复。`, 3);
362
+ }
363
+
243
364
  const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, trimBudget, compactLast, tailRenumberDelta, neutralizeOrphan, extractTurn, extractTurnTo, keepRanges, apply, backupDir });
244
365
  if (json) {
245
- process.stdout.write(JSON.stringify(result, null, 2) + '\n');
366
+ out(JSON.stringify(result, null, 2) + '\n');
246
367
  process.exit(result.ok ? 0 : 1);
247
368
  }
248
369
 
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`);
370
+ out(`\n🔧 dsh-log-contract fix —— ${file}\n`);
371
+ out(` 诊断:${result.issues.length === 0 ? '无问题' : result.issues.map((i) => `[${i.kind}] ${i.detail}`).join('\n ')}\n`);
251
372
  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`);
373
+ out(` 已应用修复:移除 ${result.removed} 项,重编号 ${result.renumbered} 行,中和 ${result.neutralized} 个 turn-null marker,裁剪 ${result.clipped} 个跨 step 引用(seq ${(result.neutralizedSeqs ?? []).join(',')})\n`);
374
+ out(` 备份:${result.backupPath}\n`);
375
+ out(` 修复后体检:error ${result.check.summary?.bySeverity?.error ?? '?'} | surface ${result.check.summary?.surfaceNodes ?? '?'} 节点\n`);
255
376
  } else if (apply && !result.ok) {
256
- process.stdout.write(' ❌ 存在 error 级问题,拒绝应用(改前基线必须绿;先修基线或检查输出)\n');
377
+ out(' ❌ 存在 error 级问题,拒绝应用(改前基线必须绿;先修基线或检查输出)\n');
257
378
  } else if (apply) {
258
- process.stdout.write(' (--apply 且无问题——无内容可修)\n');
379
+ out(' (--apply 且无问题——无内容可修)\n');
259
380
  } 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`);
381
+ 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
382
  }
262
- process.stdout.write('\n');
383
+ out('\n');
263
384
  process.exit(result.ok ? 0 : 1);
264
385
  }
265
386
 
@@ -277,11 +398,11 @@ function cmdExtract(args) {
277
398
  const log = loadSessionLog(file);
278
399
  const { pairs, total } = extractToolOutputs(log.events.map((e) => e.event), pattern, { minSize });
279
400
  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');
401
+ 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
402
  process.exit(0);
282
403
  }
283
- process.stdout.write(`\n🔍 dsh-log-contract extract —— ${file}\n`);
284
- process.stdout.write(` 命令正则:/${pattern}/ | 匹配 ${pairs.length} 个输出(共 ${total} 个工具调用,min-size ${minSize})\n`);
404
+ out(`\n🔍 dsh-log-contract extract —— ${file}\n`);
405
+ out(` 命令正则:/${pattern}/ | 匹配 ${pairs.length} 个输出(共 ${total} 个工具调用,min-size ${minSize})\n`);
285
406
  if (outDir) {
286
407
  fs.mkdirSync(outDir, { recursive: true });
287
408
  let written = 0;
@@ -290,14 +411,14 @@ function cmdExtract(args) {
290
411
  fs.writeFileSync(`${outDir}/${safe}.txt`, p.text);
291
412
  written += 1;
292
413
  }
293
- process.stdout.write(` 已导出 ${written} 个输出到 ${outDir}\n`);
414
+ out(` 已导出 ${written} 个输出到 ${outDir}\n`);
294
415
  } else {
295
416
  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`);
417
+ out(` - [${p.size}B] ${p.command.slice(0, 60)}… ${p.text.slice(0, 80).replace(/\n/g, ' ')}…\n`);
297
418
  }
298
- if (pairs.length > 3) process.stdout.write(` … 其余 ${pairs.length - 3} 个(加 --out DIR 全部导出)\n`);
419
+ if (pairs.length > 3) out(` … 其余 ${pairs.length - 3} 个(加 --out DIR 全部导出)\n`);
299
420
  }
300
- process.stdout.write('\n');
421
+ out('\n');
301
422
  process.exit(0);
302
423
  }
303
424
 
@@ -308,30 +429,30 @@ function cmdAuditReport(args) {
308
429
  const log = loadSessionLog(file);
309
430
  const report = auditToolCalls(log.events.map((e) => e.event));
310
431
  if (json) {
311
- process.stdout.write(JSON.stringify({ file, ...report }, null, 2) + '\n');
432
+ out(JSON.stringify({ file, ...report }, null, 2) + '\n');
312
433
  process.exit(0);
313
434
  }
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`);
435
+ out(`\n📊 dsh-log-contract audit-report —— ${file}\n`);
436
+ out(` 工具调用 ${report.calls} | 结果 ${report.results} | 孤儿 ${report.orphans} | 配对率 ${(report.pairingRate * 100).toFixed(1)}%\n`);
437
+ out(` 输出总字节 ${report.outputBytes}`);
438
+ if (report.largest) out(` | 最大 ${report.largest.size}B(${(report.largest.command || '?').slice(0, 40)})`);
439
+ out(`\n 命令分布(前 ${report.commands.top.length} 个去重):\n`);
319
440
  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`);
441
+ out(` ${String(count).padStart(4)} ${(command || '(no-command)').slice(0, 70)}\n`);
321
442
  }
322
- process.stdout.write('\n');
443
+ out('\n');
323
444
  process.exit(0);
324
445
  }
325
446
 
326
447
  const args = process.argv.slice(2);
327
448
  const cmd = args[0];
328
449
  if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
329
- process.stdout.write(USAGE);
450
+ out(USAGE);
330
451
  process.exit(0);
331
452
  }
332
453
  if (cmd === '--version' || cmd === '-v') {
333
454
  const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
334
- process.stdout.write(`dsh-log-contract ${pkg.version}\n`);
455
+ out(`dsh-log-contract ${pkg.version}\n`);
335
456
  process.exit(0);
336
457
  }
337
458
  if (cmd === 'check') cmdCheck(args.slice(1));