dsh-log-contract 0.3.5 → 0.3.7

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.zh.md ADDED
@@ -0,0 +1,253 @@
1
+ <div align="center">
2
+
3
+ # 🔒 dsh-log-contract
4
+
5
+ **日志契约守护** —— DSH 会话日志的结构契约保险丝:离线体检 + 写前校验。业务层的**医生**。
6
+
7
+ [![npm version](https://img.shields.io/npm/v/dsh-log-contract)](https://www.npmjs.com/package/dsh-log-contract)
8
+ [![npm downloads](https://img.shields.io/npm/dm/dsh-log-contract)](https://www.npmjs.com/package/dsh-log-contract)
9
+ [![License: MIT](https://img.shields.io/npm/l/dsh-log-contract)](https://github.com/yamingmou/dsh-log-contract/blob/main/LICENSE)
10
+ [![DSH plugin](https://img.shields.io/badge/DSH-plugin-4A90D9)](https://github.com/topics/dsh-plugin)
11
+ [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](https://github.com/yamingmou/dsh-log-contract/pulls)
12
+
13
+ [English](./README.md) · **简体中文**
14
+
15
+ </div>
16
+
17
+ # dsh-log-contract · 日志契约守护
18
+
19
+
20
+ > DSH(DeepSeek Harness)会话日志的**结构契约保险丝**:离线体检 + 写前校验。
21
+ > 原名 `log-contract-validator`(候选二号),按 Offer快 三件套规划定名 **`dsh-log-contract`**。
22
+
23
+ 给 DSH 会话日志(`*.jsonl` / `*.jsonl.zstd`)装一条保险丝:人眼看不出、程序解析会崩的日志格式漂移,在它这里被拦下并告警。它不判断日志**内容**对不对,只守护日志**结构**是否破坏了下游消费者(Harness 读路径、客户端引擎、插件 marker 语义)的预期。
24
+
25
+ - **`check <session-log>`** —— 离线体检:官方解码器全量解码 + 契约逐条校验 + foldSurface 终验,产出违规报告。
26
+ - **`prewrite <edit-file> --log <session-log>`** —— ★ 写前校验:任何写入(追加 / 帧级手术)在落盘之前先过三层契约,违约即拦。
27
+ - **`contracts`** —— 列出内置契约规则目录(每条附官方源码出处)。
28
+
29
+ ---
30
+
31
+ ## 它在业务层里的位置
32
+
33
+ > **dsh-log-contract 是 [dsh-retrace](https://github.com/yamingmou/dsh-retrace) 的核心能力组件**(业务层的「医生」模块):负责会话日志的**体检与修复**——让每一次撤回/编辑/回退都落在合法日志上,让 /compact 永不失效。
34
+
35
+ | 层 | 是什么 | 组件 |
36
+ |---|---|---|
37
+ | **Agent 业务层(生产级保证)** | 抽象核心能力:会话卫生 / 可回溯 / 可审计 / 可恢复,与平台无关 | 四模块:治理 / 看 / 考古 / **医生** |
38
+ | **dsh-retrace** | 业务层在 DeepSeek Harness 上的实现(生产级业务插件) | 撤回/编辑/版本/回退/看门狗 |
39
+ | **dsh-log-contract** | dsh-retrace 的核心能力组件 = 业务层的**医生**(体检/修复) | check / prewrite / fix / extract / audit |
40
+
41
+ **含义**:dsh-log-contract 独立发布(供单独使用或二次开发),但它首先是
42
+ dsh-retrace 的「日志体检与修复」能力——与 dsh-retrace 一起构成
43
+ **Agent 业务层(生产级保证)** 在 DSH 上的落地(详见 [dsh-retrace 路线图](https://github.com/yamingmou/dsh-retrace/blob/main/docs/ROADMAP.md))。
44
+
45
+ ---
46
+
47
+ ## 为什么需要它
48
+
49
+ **#3632「one log, two consumers, two verdicts」**:一条日志同时被人类与自动化程序消费,人眼容忍格式微调,程序解析依赖严格契约;格式一旦漂移,人看不出问题,程序直接崩溃或误报。
50
+
51
+ **这里每一条规则都来自真实事故**——见下方 [⚡ 事故记录](#-事故记录)。每起事故都是一个回归夹具:损坏的会话本工具必须报出,修复后的会话必须通过。
52
+
53
+ ---
54
+
55
+ ## 三层契约(判定模型)
56
+
57
+ > 30+ 条规则,覆盖以下三层(`contracts` 列出全部,每条附官方源码出处)。
58
+
59
+ | 层 | 规则 | 守护什么 |
60
+ |---|---|---|
61
+ | **持久化层** | H/R/E/S(含 **S5**)+ **S9** | seq 连续、type 已知、surfaceOp 合法、`sourceEventSeqs` 完整覆盖被替换节点、文件物理序单调、`foldSurface` 不抛 |
62
+ | **客户端引擎层** | **M1** + **T1 / T2 / I1** | turn-null marker 只能 replace;token-meter 配对;跨 step 源引用;inbox seed 相对重放 |
63
+ | **wire 消息流** | **W1 / W2** | tool 消息跟在带 tool_calls 的 assistant 之后;user 文本不插在 tool_calls 与结果之间 |
64
+ | **插件语义层** | P1/P2 | marker 前缀可识别;marker 自身 seq 不进自身 shadowed 集 |
65
+
66
+ > 校验哲学:先用与官方同语义的增量重放做**逐事件归因**(定位到 seq/行号),再跑官方 `foldSurface` 做**终验**(不抛才算过)——两套都绿才过。
67
+
68
+ ---
69
+
70
+ ## ⚡ 事故记录 ——「生产级保证」不是口号
71
+
72
+ 下面的每一条规则都来自我们工作区的一起**真实事故**。日期与形态真实,会话 id 为隐私省略。
73
+
74
+ | # | 日期 | 发生了什么 | 产出的规则/修复 |
75
+ |---|---|---|---|
76
+ | 1 | 2026-08-25 | 一次「恢复被隐藏内容」的修复写了**清空 `sourceEventSeqs`** 的 replace marker → 会话加载被拒(`SessionPersistenceCorruptionError`);第二次尝试把 marker 改成 **append** → 客户端引擎崩溃。两次都是**违约写入没被拦**。 | **S5**(sourceEventSeqs 必须覆盖被替换节点)、**M1**(turn-null 的 assistant/message 只能 replace)、写前校验 |
77
+ | 2 | 2026-08-27~28 | 中断/暂停的轮次恢复时按**过期内存光标**重放,把旧 seq 追加到文件尾(尾部回归、重复批次);两个写入者交织 → **文件物理序非单调**(`734056 → 733539 → 735470`)。会话 `seq gap` 加载失败。 | **S9**(物理序单调)、fix `--tail-renumber` |
78
+ | 3 | 2026-08-27~28 | **fork 边界孤儿 spliced**:fork 的「移除父待处理提示词」splice 假设父会话 inbox;子会话 seed 相对重放里 inbox 为空 → `resume failed: invalid persisted inbox splice`。 | **I1**(inbox seed 相对重放)、fix `--neutralize-orphan` |
79
+ | 4 | 2026-08-28 | 超限会话(**1,052,557 tokens** vs 1M 窗口)既无法继续也无法 `/compact`;裁剪预算估算对中文低估 ~3.7×。 | T1(token-meter 配对,保障可压缩)、`fix --trim` 预算指引 |
80
+ | 5 | 2026-08-29 | **W1/W2 wire 违规**:marker 遮蔽了带 tool_calls 的 assistant 但漏盖 tool 结果 → 悬空 tool,严格端点 `INVALID_REQUEST` 拒绝请求流。 | **W1 / W2**(wire 消息流) |
81
+ | 6 | 2026-08-30 | 单个 **turn-null marker** 让 token-meter 监听器在**每条**追加事件上抛错(`consumedEvents` 不前进 → 每事件全前缀重折)→ **30 秒 / 10,008 行日志**、host 事件循环被压垮、全部会话锁定。同会话还有**跨 step sourceEventSeqs**(step 7/8/9 混进一条 assistant 消息)——离线 check 全绿、实机 meter 崩溃。 | **T1**(turn/step 配对)、**T2**(跨 step 源引用)、`fix --neutralize`、`fix --clip-crossstep` |
82
+
83
+ > **结论**:这个工具的每条规则都是一次真实会话留下的疤——用真实的损坏会话夹具验证过,不是合成理论。这就是这里「生产级」的含义。
84
+
85
+ ---
86
+
87
+ ## 安装
88
+
89
+ ```bash
90
+ pnpm add -D dsh-log-contract # 或 npm install
91
+ pnpm dlx dsh-log-contract --help
92
+ ```
93
+
94
+ > **你是 dsh-retrace 用户?** 无需单独安装——`dsh-retrace` 已把 `dsh-log-contract`
95
+ > 声明为依赖,装 retrace 时自动带好契约守护(体检/写前校验/修复原语全部随插件生效)。
96
+ > 本包独立发布,供愿意单独使用或二次开发的用户直接引入。
97
+ >
98
+ > **从 GitHub 下载了 ZIP?** 解压后 `cd dsh-log-contract && npm install && npm run build`,
99
+ > 然后 `node bin/dsh-log-contract.mjs check <session-log>` 即可使用(无需全局安装)。
100
+
101
+ 依赖:Node ≥ 22(`node:zlib` 内置 zstd)、`@deepseek-ai/dsh-session`(peer,校验/解码复用官方实现,保证与 Harness 读路径同源)。
102
+
103
+ ---
104
+
105
+ ## CLI 用法
106
+
107
+ ### 1. 离线体检
108
+
109
+ ```bash
110
+ dsh-log-contract check ~/.dsh/sessions/<id>.jsonl.zstd
111
+ dsh-log-contract check ~/.dsh/sessions/<id>.jsonl.zstd --json # 机器可读
112
+ ```
113
+
114
+ 输出示例:
115
+
116
+ ```
117
+ 📋 dsh-log-contract check —— backup-session-xxxx.jsonl.zstd
118
+ 事件 204754 | surface 节点 16 | replace 代数 5 | 帧 8620(3439.5KiB → 8191.3KiB)
119
+ 违规 1(error 1 / warning 0)
120
+
121
+ [error] S5 @ seq 156425 / line 778 (assistant/message)
122
+ surface replace: sourceEventSeqs 必须覆盖每个被替换节点;缺失 121774, 121779(共 2 个)
123
+
124
+ ❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)
125
+ ```
126
+
127
+ 退出码:0 = 通过(无 error 级违规);1 = 存在 error 级违规。
128
+
129
+ `check` 自 0.2.0 起新增 **W1/W2 wire 级检查**:按 surface 顺序展开模型请求消息流,
130
+ 捕获"悬空 tool 消息"(tool 结果没有前置 assistant tool_calls)与"user 文本插在
131
+ tool_calls 与其结果之间"——这类问题 DeepSeek 曾容忍,但 MiMo 等严格端点会直接
132
+ `INVALID_REQUEST`(2026-08-27 实锤)。
133
+
134
+ ### 2. 修复(`fix`)
135
+
136
+ ```bash
137
+ # 干跑(只报告):严格 seq 连续扫描 + 全契约体检(含 W1/W2)+ 可移除 marker 数
138
+ dsh-log-contract fix ~/.dsh/sessions/<id>.jsonl.zstd --remove-markers
139
+
140
+ # 应用:备份后落盘(.zstd 走官方帧格式重建:帧1=header、帧2=其余、checksum、单个结尾换行)
141
+ dsh-log-contract fix ~/.dsh/sessions/<id>.jsonl.zstd --remove-markers --apply
142
+ ```
143
+
144
+ - `--remove-markers`:移除 retrace/message-editor marker 并全量重编号
145
+ (seq/seq0/sourceEventSeqs/surfaceOp 同步)——用于大范围 marker 遮蔽历史、
146
+ marker 漏盖 tool/result 导致的悬空 tool。
147
+ - `--neutralize`:原地中和 turn-null marker(事故 #6)——type → `retrace/marker` +
148
+ `ignorable:true`,删 surfaceOp/sourceEventSeqs,seq/行数不变(会话驻留也安全)。
149
+ - `--clip-crossstep`:裁剪跨 step sourceEventSeqs(事故 #6)——只保留同 turn/step 的 chunk 引用。
150
+ - 手术安全协议:改前备份、改后全量复检(strictScan + check + foldSurface)、
151
+ marker 只能遮蔽其之前的节点、marker 绝不能改成 append(M1 客户端崩溃)。
152
+ - ⚠️ 若会话已被运行中的应用驻留内存,修复文件后需**重启应用**(强杀避免脏状态刷回)。
153
+
154
+ ### 3. 写前校验(`prewrite`)
155
+
156
+ `edit-file` 为 JSON,两种形状:
157
+
158
+ ```jsonc
159
+ // 拟追加一个事件到日志尾部(seq 缺省 = 自动按 nextSeq 赋值)
160
+ { "append": { "type": "assistant/message", "surfaceOp": { "op": "replace", "start": 121774, "end": 156421 }, "sourceEventSeqs": [121774, 121779, "…"], "data": { "turn": null, "step": null, "message": { "…": "…" }, "editor": { "targetSeq": 156430, "text": "…" } } } }
161
+
162
+ // 帧级手术后的完整事件列表(改后确认,与改前基线双绿才允许落盘)
163
+ { "edit": [ "…完整事件列表…" ] }
164
+ ```
165
+
166
+ ```bash
167
+ dsh-log-contract prewrite marker-write.json --log ~/.dsh/sessions/<id>.jsonl.zstd
168
+ ```
169
+
170
+ - 基线本身有 error 级违规时直接拒绝校验(安全修复协议第 2 步:**改前基线必须绿**)。
171
+ - 判定通过才允许落盘——**validate first, commit later**(与官方 `SurfaceManager.validateNext` 同思路)。
172
+
173
+ ### 4. 契约目录
174
+
175
+ ```bash
176
+ dsh-log-contract contracts
177
+ ```
178
+
179
+ 完整契约清单见 [docs/CONTRACTS.md](docs/CONTRACTS.md)。
180
+
181
+ ### 5. 会话考古(`extract` / `audit-report`)
182
+
183
+ DSH 会话日志持久化了每次工具调用的完整输入输出——数据资产与审计资产。
184
+ 只读考古能力:
185
+
186
+ ```sh
187
+ # 按命令正则导出工具输出(保留原始文本)
188
+ dsh-log-contract extract <session-log> --pattern "seed-scale" --min-size 50 --out ./found
189
+
190
+ # 考古审计报告:调用数 / 配对率 / 孤儿数 / 命令分布
191
+ dsh-log-contract audit-report <session-log>
192
+ ```
193
+
194
+ 契约规则 P3(tool/call↔tool/result 配对完整性)与 P4(输出结构可解析)
195
+ 守护"挖得动":孤儿调用、text 字段异常在 check 中告警。
196
+
197
+ ---
198
+
199
+ ## Node API(写前校验嵌入你的脚本)
200
+
201
+ ```js
202
+ import { loadSessionLog, validateSessionLog, createPreWriter } from 'dsh-log-contract';
203
+
204
+ // ① 基线体检(改前基线必须绿)
205
+ const log = loadSessionLog('session.jsonl.zstd');
206
+ const baseline = validateSessionLog(log);
207
+ if (!baseline.ok) throw new Error('基线已坏,先修基线');
208
+
209
+ // ② 写前校验:拟写入一个 marker replace
210
+ const prewriter = createPreWriter({ events: log.events.map((e) => e.event) });
211
+ const verdict = prewriter.validateAppend({
212
+ type: 'assistant/message',
213
+ surfaceOp: { op: 'replace', start: 121774, end: 156421 },
214
+ sourceEventSeqs: [121774, 121779 /* …必须完整覆盖被替换节点… */],
215
+ data: { turn: null, step: null, message: { /* … */ } },
216
+ });
217
+ if (!verdict.ok) {
218
+ for (const v of verdict.violations) console.error(v.id, v.message);
219
+ process.exit(1); // 不落盘
220
+ }
221
+ // ③ 通过后才写
222
+ ```
223
+
224
+ ---
225
+
226
+ ## 测试
227
+
228
+ ```bash
229
+ pnpm check && pnpm test # 语法检查 + 79 个单测(含事故回归用例)
230
+ ```
231
+
232
+ - **合成夹具**(入库):合法会话 / seq 缺口 / 空 sourceEventSeqs / turn=null append / 未知 type / 坏 chunk 行 / 撕裂尾帧 / 未知 marker 前缀 / 自指 shadowed 等。
233
+ - **真实化石**(不入库,含用户隐私):本地跑
234
+
235
+ ```bash
236
+ node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.zstd
237
+ ```
238
+
239
+ 已知真值表:事故修复后会话 PASS;`seqgap`/`corrupt`/`rewritten-230542` FAIL;`spliced-orphan` PASS(持久化层合法——#3632 的"消费路径判不可读"属于另一类契约,本工具只守护持久化契约层,见 [docs/CONTRACTS.md](docs/CONTRACTS.md) 边界说明)。
240
+
241
+ ---
242
+
243
+ ## Roadmap
244
+
245
+ - [x] **Phase 1(0.1.0)**:CLI 离线体检 + 写前校验 + 契约目录
246
+ - [x] **Phase 1.5(0.2.0)**:`fix` 子命令(严格 seq 扫描 + W1/W2 wire 检查 + 移除 marker 重编号 + 官方帧格式重建);CI 集成(`dsh-log-contract check` 作为 Harness 会话目录的定时守护)
247
+ - [x] **0.3.x(2026-08-30 事故固化)**:T1 token-meter 配对 → 0.3.1 W1/W2 折叠位置修复 → 0.3.2 `tailSeq` → 0.3.3 `fix --neutralize`(turn-null marker 原地中和)→ 0.3.4 `fix --clip-crossstep`(跨 step 引用裁剪)→ 0.3.5 **T2/S9/I1 规则**(跨 step 源引用 / 物理序单调 / inbox 重放)
248
+ - [ ] Phase 2:运行时守护(订阅 session append 事件流实时校验,断裂即标记 `dsh/contract-violation` 事件,策略可配 告警/拦截)——DSH 插件形态
249
+ - [ ] Phase 3:与 dsh-turn-guard / dsh-retrace 时间线联动
250
+
251
+ ## 许可
252
+
253
+ MIT © OfferKuai Team
@@ -13,17 +13,19 @@
13
13
  * contracts 列出内置契约规则目录
14
14
  */
15
15
  import fs from 'node:fs';
16
- import { loadSessionLog, validateSessionLog, createPreWriter, repairSession, CONTRACT_RULES, ruleById, extractToolOutputs, auditToolCalls } from '../lib/index.js';
16
+ import { loadSessionLog, validateSessionLog, resumeVerdict, createPreWriter, repairSession, CONTRACT_RULES, ruleById, extractToolOutputs, auditToolCalls } from '../lib/index.js';
17
17
 
18
18
  const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log contract guard)
19
19
 
20
20
  用法:
21
- dsh-log-contract check <session-log> [--json] [--max-details N]
21
+ dsh-log-contract check <session-log> [--json] [--max-details N] [--resume]
22
22
  离线体检。session-log 支持 .jsonl 与 .jsonl.zstd。
23
23
  --json 输出机器可读 JSON 报告
24
24
  --max-details N 每条违规最多列 N 个缺失 seq(默认 8,--json 忽略)
25
+ --resume 输出三档结论(L3):可加载 / 可继续 / 可压缩——
26
+ 回答「这个会话还能不能用」;--json 时附带 verdict 字段
25
27
 
26
- dsh-log-contract fix <session-log> [--remove-markers] [--neutralize] [--clip-crossstep] [--apply] [--backup-dir DIR] [--json]
28
+ 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]
27
29
  诊断 + 修复(2026-08 事故固化方案)。先做严格 seq 连续扫描 + 契约体检
28
30
  (含 W1/W2 wire 级悬空 tool 检查),再按需修复:
29
31
  --remove-markers 移除 retrace/message-editor marker 并全量重编号
@@ -32,6 +34,21 @@ const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log cont
32
34
  --neutralize 原地中和 turn-null marker(type→retrace/marker +
33
35
  ignorable:true,删 surfaceOp/sourceEventSeqs,seq/行数不变)
34
36
  —— token-meter 不再刷屏,会话驻留也安全(2026-08-30 事故)
37
+ --drop-failed-turns 删除"本轮运行失败"的轮次(清失败报错气泡)
38
+ --trim-last N 裁剪到最近 N 条 append 消息(保留所在 turn 结构)
39
+ --trim-budget N 按 token 预算裁剪(L5):自动选保留消息数使估算 ≤ N
40
+ (中文 ≈ 字符数×0.94,不是 ÷4;下限至少保留 5 条消息)
41
+ --compact-last N 官方压缩:遮蔽旧 surface 节点,日志零删除(/compact 语义)
42
+ --tail-renumber D 尾部 seq 统一平移(delta 是减数:要加 N 传 −N)
43
+ (修复多写入者/旧光标造成的尾部 seq 回归/间隙;从首个
44
+ 可平移 seq 起)
45
+ --neutralize-orphan 原地归零 fork 边界孤儿 inbox spliced(removedCount→0,
46
+ seq/行数不变 → 不再重复排队)
47
+ --extract-turn N 双流交织恢复:只保留轮次 N + 无 turn 系统事件,其余
48
+ 删除并全量重编号(--extract-turn-to M 把第二个同名
49
+ 轮次改号为 M)
50
+ --keep-ranges a-b,c-d 只保留 1-based 行区间(含端点),其余删除 +
51
+ 全量重编号(header 行永远保留)
35
52
  --apply 备份后落盘(.zstd 走官方帧格式重建:帧1=header、
36
53
  帧2=其余、带 checksum、单个结尾换行)
37
54
  不传 --apply 为干跑(只报告)。
@@ -79,6 +96,7 @@ function printViolations(violations, maxDetails = 8) {
79
96
 
80
97
  function cmdCheck(args) {
81
98
  const json = args.includes('--json');
99
+ const resume = args.includes('--resume');
82
100
  const maxDetailsIdx = args.indexOf('--max-details');
83
101
  const maxDetails = maxDetailsIdx >= 0 && args[maxDetailsIdx + 1] ? Number(args[maxDetailsIdx + 1]) : 8;
84
102
  const file = args.find((a) => !a.startsWith('-'));
@@ -94,7 +112,32 @@ function cmdCheck(args) {
94
112
  const { summary, violations, ok } = result;
95
113
 
96
114
  if (json) {
97
- process.stdout.write(JSON.stringify({ file, ok, summary, violations }, null, 2) + '\n');
115
+ const payload = { file, ok, summary, violations };
116
+ if (resume) payload.resume = resumeVerdict(result);
117
+ process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
118
+ process.exit(ok ? 0 : 1);
119
+ }
120
+
121
+ if (resume) {
122
+ const v = resumeVerdict(result);
123
+ 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`);
129
+ const tiers = [
130
+ ['可加载 loadable', v.loadable, '会话能被 DSH 读入(结构规则 S1-S9/E/W 全绿)', v.blocking.loadable],
131
+ ['可继续 resumable', v.resumable, 'resume/followup 可用(结构 + I1 inbox 重放绿)', v.blocking.resumable],
132
+ ['可压缩 compactable', v.compactable, '/compact 与压力测量可用(前两档 + T1/T2 token-meter 配对绿)', v.blocking.compactable],
133
+ ];
134
+ 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`);
138
+ }
139
+ process.stdout.write(`\n 结论: ${icons[tier]}${v.verdict === 'compactable' ? ' —— 可安全继续使用' : v.verdict === 'broken' ? ' —— 见上方违规明细(error 级 = 会话不可读/不可写)' : ' —— 部分能力受限'}\n\n`);
140
+ printViolations(violations, maxDetails);
98
141
  process.exit(ok ? 0 : 1);
99
142
  }
100
143
 
@@ -177,15 +220,27 @@ function cmdFix(args) {
177
220
  const dropFailedTurns = args.includes('--drop-failed-turns');
178
221
  const trimIdx = args.indexOf('--trim-last');
179
222
  const trimLast = trimIdx >= 0 && args[trimIdx + 1] ? Number(args[trimIdx + 1]) : undefined;
223
+ const budgetIdx = args.indexOf('--trim-budget');
224
+ const trimBudget = budgetIdx >= 0 && args[budgetIdx + 1] ? Number(args[budgetIdx + 1]) : undefined;
180
225
  const compactIdx = args.indexOf('--compact-last');
181
226
  const compactLast = compactIdx >= 0 && args[compactIdx + 1] ? Number(args[compactIdx + 1]) : undefined;
227
+ // L4 新原语(2026-08-30 任务书 §L4 收编 tools/)
228
+ const tailIdx = args.indexOf('--tail-renumber');
229
+ const tailRenumberDelta = tailIdx >= 0 && args[tailIdx + 1] ? Number(args[tailIdx + 1]) : undefined;
230
+ const neutralizeOrphan = args.includes('--neutralize-orphan');
231
+ const extractIdx = args.indexOf('--extract-turn');
232
+ const extractTurn = extractIdx >= 0 && args[extractIdx + 1] ? Number(args[extractIdx + 1]) : undefined;
233
+ const extractToIdx = args.indexOf('--extract-turn-to');
234
+ const extractTurnTo = extractToIdx >= 0 && args[extractToIdx + 1] ? Number(args[extractToIdx + 1]) : undefined;
235
+ const keepRangesIdx = args.indexOf('--keep-ranges');
236
+ const keepRanges = keepRangesIdx >= 0 && args[keepRangesIdx + 1] ? args[keepRangesIdx + 1] : undefined;
182
237
  const apply = args.includes('--apply');
183
238
  const backupDirIdx = args.indexOf('--backup-dir');
184
239
  const backupDir = backupDirIdx >= 0 && args[backupDirIdx + 1] ? args[backupDirIdx + 1] : undefined;
185
240
  const file = args.find((a) => !a.startsWith('-'));
186
241
  if (!file) fail(USAGE);
187
242
 
188
- const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, compactLast, apply, backupDir });
243
+ const result = repairSession(file, { removeMarkers, neutralize, clipCrossStep, dropFailedTurns, trimLast, trimBudget, compactLast, tailRenumberDelta, neutralizeOrphan, extractTurn, extractTurnTo, keepRanges, apply, backupDir });
189
244
  if (json) {
190
245
  process.stdout.write(JSON.stringify(result, null, 2) + '\n');
191
246
  process.exit(result.ok ? 0 : 1);
@@ -202,7 +257,7 @@ function cmdFix(args) {
202
257
  } else if (apply) {
203
258
  process.stdout.write(' (--apply 且无问题——无内容可修)\n');
204
259
  } else {
205
- 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 启用于对应修复)\n`);
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`);
206
261
  }
207
262
  process.stdout.write('\n');
208
263
  process.exit(result.ok ? 0 : 1);
package/lib/checks.js CHANGED
@@ -452,6 +452,7 @@ export function tokenMeterSourceViolations(events) {
452
452
  const turn = event.data?.turn;
453
453
  const step = event.data?.step;
454
454
  if (turn == null || step == null) continue; // turn-null marker 由 T1 覆盖
455
+ if (event.data?.usage === void 0) continue; // 无 usage 不触发 _estimateProviderAssistant(官方 :592 前提)——replace marker(S5 遮蔽语义,sourceEventSeqs 含非 chunk 节点)合法
455
456
  const seen = new Set();
456
457
  for (const s of event.sourceEventSeqs) {
457
458
  if (s >= event.seq) {
@@ -464,7 +465,10 @@ export function tokenMeterSourceViolations(events) {
464
465
  }
465
466
  seen.add(s);
466
467
  const src = bySeq.get(s);
467
- if (!src || src.event.type !== 'assistant/chunk') continue; // 非 chunk 引用官方跳过
468
+ if (!src || src.event.type !== 'assistant/chunk') {
469
+ out.push(violation('T2', { seq: event.seq, lineNo, eventType: event.type }, `assistant/message at seq ${event.seq} source seq ${s} is not assistant/chunk(实际 ${src ? src.event.type : 'MISSING'})——token meter 折叠会抛错(_estimateProviderAssistant :644 对非 chunk 引用直接 throw),/compact 与压力测量永久失败`));
470
+ break; // 官方抛一次即停(consumedEvents 不前进),只报首条
471
+ }
468
472
  if (src.event.data?.turn !== turn || src.event.data?.step !== step) {
469
473
  out.push(violation('T2', { seq: event.seq, lineNo, eventType: event.type }, `assistant/message at seq ${event.seq} source seq ${s} belongs to another step(消息 turn ${turn}/step ${step},源 turn ${String(src.event.data?.turn)}/step ${String(src.event.data?.step)})——token meter 折叠会抛错,/compact 与压力测量永久失败(DSH resend 在 step 未关时跨 step 引用)`));
470
474
  break; // 官方抛一次即停(consumedEvents 不前进),只报首条
package/lib/index.js CHANGED
@@ -5,9 +5,9 @@
5
5
  * 公开 API:离线体检 + 写前校验 + 修复 + 契约目录。
6
6
  */
7
7
  export { loadSessionLog, tailSeq } from './log-reader.js';
8
- export { validateSessionLog } from './validate.js';
8
+ export { validateSessionLog, resumeVerdict } from './validate.js';
9
9
  export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
- export { repairSession, strictScanText, removeMarkersText, neutralizeMarkersText, clipCrossStepSourcesText, dropFailedTurnsText, trimLastMessagesText, compactLastMessagesText, rebuildZstdText } from './repair.js';
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 { tokenMeterViolations, tokenMeterSourceViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
13
13
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';