dsh-log-contract 0.1.0
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/LICENSE +21 -0
- package/README.md +163 -0
- package/bin/dsh-log-contract.mjs +165 -0
- package/docs/CONTRACTS.md +209 -0
- package/lib/checks.js +266 -0
- package/lib/contracts.js +267 -0
- package/lib/index.js +10 -0
- package/lib/log-reader.js +179 -0
- package/lib/prewrite.js +155 -0
- package/lib/validate.js +147 -0
- package/package.json +68 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OfferKuai Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# dsh-log-contract · 日志契约守护
|
|
2
|
+
|
|
3
|
+
> DSH(DeepSeek Harness)会话日志的**结构契约保险丝**:离线体检 + 写前校验。
|
|
4
|
+
> 原名 `log-contract-validator`(候选二号),按 Offer快 三件套规划定名 **`dsh-log-contract`**。
|
|
5
|
+
|
|
6
|
+
给 DSH 会话日志(`*.jsonl` / `*.jsonl.zstd`)装一条保险丝:人眼看不出、程序解析会崩的日志格式漂移,在它这里被拦下并告警。它不判断日志**内容**对不对,只守护日志**结构**是否破坏了下游消费者(Harness 读路径、客户端引擎、插件 marker 语义)的预期。
|
|
7
|
+
|
|
8
|
+
- **`check <session-log>`** —— 离线体检:官方解码器全量解码 + 契约逐条校验 + foldSurface 终验,产出违规报告。
|
|
9
|
+
- **`prewrite <edit-file> --log <session-log>`** —— ★ 写前校验:任何写入(追加 / 帧级手术)在落盘之前先过三层契约,违约即拦。
|
|
10
|
+
- **`contracts`** —— 列出内置契约规则目录(每条附官方源码出处)。
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 为什么需要它
|
|
15
|
+
|
|
16
|
+
**#3632「one log, two consumers, two verdicts」**:一条日志同时被人类与自动化程序消费,人眼容忍格式微调,程序解析依赖严格契约;格式一旦漂移,人看不出问题,程序直接崩溃或误报。
|
|
17
|
+
|
|
18
|
+
**2026-08-25 会话修复事故(真实回归用例)**:一次"恢复被隐藏内容"的修复,第 1 轮清空 marker 的 `sourceEventSeqs` 直接写盘 → 会话加载抛 `SessionPersistenceCorruptionError`;第 2 轮把 marker 改成 `append` → 客户端引擎崩溃。两次都是**违约写入没被拦**。如果有写前校验,会话根本不会被改坏。本工具把这次事故沉淀为两条核心规则(S5、M1)与回归测试。
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 三层契约(判定模型)
|
|
23
|
+
|
|
24
|
+
| 层 | 契约 | 本工具 |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| **持久化层** | seq 严格连续;type 在已知词汇表内;surface 事件携带合法 `surfaceOp`;replace 的 `sourceEventSeqs` 必须**完整覆盖被替换节点**;官方 `foldSurface` 不抛 = 通过 | 规则 H/R/E/S(含 S5 核心) |
|
|
27
|
+
| **客户端引擎层** | `data.turn/step` 为 null 的 `assistant/message` 只能以 **replace** 承载(插件 marker 定义),append 会触发引擎崩溃 | 规则 M1 |
|
|
28
|
+
| **插件语义层** | marker id 前缀必须可识别(改名登记遗留前缀);marker 自身 seq 不得进入自身 shadowed 集 | 规则 P1/P2 |
|
|
29
|
+
|
|
30
|
+
> 校验哲学:先用与官方同语义的增量重放做**逐事件归因**(定位到 seq/行号),再跑官方 `foldSurface` 做**终验**(不抛才算过)——两套都绿才过。
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add -D dsh-log-contract # 或 npm install
|
|
38
|
+
pnpm dlx dsh-log-contract --help
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
依赖:Node ≥ 22(`node:zlib` 内置 zstd)、`@deepseek-ai/dsh-session`(peer,校验/解码复用官方实现,保证与 Harness 读路径同源)。
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## CLI 用法
|
|
46
|
+
|
|
47
|
+
### 1. 离线体检
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
dsh-log-contract check ~/.dsh/sessions/<id>.jsonl.zstd
|
|
51
|
+
dsh-log-contract check ~/.dsh/sessions/<id>.jsonl.zstd --json # 机器可读
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
输出示例:
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
📋 dsh-log-contract check —— backup-session-xxxx.jsonl.zstd
|
|
58
|
+
事件 204754 | surface 节点 16 | replace 代数 5 | 帧 8620(3439.5KiB → 8191.3KiB)
|
|
59
|
+
违规 1(error 1 / warning 0)
|
|
60
|
+
|
|
61
|
+
[error] S5 @ seq 156425 / line 778 (assistant/message)
|
|
62
|
+
surface replace: sourceEventSeqs 必须覆盖每个被替换节点;缺失 121774, 121779(共 2 个)
|
|
63
|
+
|
|
64
|
+
❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
退出码:0 = 通过(无 error 级违规);1 = 存在 error 级违规。
|
|
68
|
+
|
|
69
|
+
### 2. ★ 写前校验(本次事故的直接解药)
|
|
70
|
+
|
|
71
|
+
`edit-file` 为 JSON,两种形状:
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
// 拟追加一个事件到日志尾部(seq 缺省 = 自动按 nextSeq 赋值)
|
|
75
|
+
{ "append": { "type": "assistant/message", "surfaceOp": { "op": "replace", "start": 121774, "end": 156421 }, "sourceEventSeqs": [121774, 121779, "…"], "data": { "turn": null, "step": null, "message": { "…": "…" }, "editor": { "targetSeq": 156430, "text": "…" } } } }
|
|
76
|
+
|
|
77
|
+
// 帧级手术后的完整事件列表(改后确认,与改前基线双绿才允许落盘)
|
|
78
|
+
{ "edit": [ "…完整事件列表…" ] }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
dsh-log-contract prewrite marker-write.json --log ~/.dsh/sessions/<id>.jsonl.zstd
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
- 基线本身有 error 级违规时直接拒绝校验(安全修复协议第 2 步:**改前基线必须绿**)。
|
|
86
|
+
- 判定通过才允许落盘——**validate first, commit later**(与官方 `SurfaceManager.validateNext` 同思路)。
|
|
87
|
+
|
|
88
|
+
### 3. 契约目录
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
dsh-log-contract contracts
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
完整契约清单见 [docs/CONTRACTS.md](docs/CONTRACTS.md)。
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Node API(写前校验嵌入你的脚本)
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
import { loadSessionLog, validateSessionLog, createPreWriter } from 'dsh-log-contract';
|
|
102
|
+
|
|
103
|
+
// ① 基线体检(改前基线必须绿)
|
|
104
|
+
const log = loadSessionLog('session.jsonl.zstd');
|
|
105
|
+
const baseline = validateSessionLog(log);
|
|
106
|
+
if (!baseline.ok) throw new Error('基线已坏,先修基线');
|
|
107
|
+
|
|
108
|
+
// ② 写前校验:拟写入一个 marker replace
|
|
109
|
+
const prewriter = createPreWriter({ events: log.events.map((e) => e.event) });
|
|
110
|
+
const verdict = prewriter.validateAppend({
|
|
111
|
+
type: 'assistant/message',
|
|
112
|
+
surfaceOp: { op: 'replace', start: 121774, end: 156421 },
|
|
113
|
+
sourceEventSeqs: [121774, 121779 /* …必须完整覆盖被替换节点… */],
|
|
114
|
+
data: { turn: null, step: null, message: { /* … */ } },
|
|
115
|
+
});
|
|
116
|
+
if (!verdict.ok) {
|
|
117
|
+
for (const v of verdict.violations) console.error(v.id, v.message);
|
|
118
|
+
process.exit(1); // 不落盘
|
|
119
|
+
}
|
|
120
|
+
// ③ 通过后才写
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 测试
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
pnpm check && pnpm test # 语法检查 + 40 个单测(含事故回归用例)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
- **合成夹具**(入库):合法会话 / seq 缺口 / 空 sourceEventSeqs / turn=null append / 未知 type / 坏 chunk 行 / 撕裂尾帧 / 未知 marker 前缀 / 自指 shadowed 等。
|
|
132
|
+
- **真实化石**(不入库,含用户隐私):本地跑
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node scripts/check-local-fossils.mjs # 扫描 ../ 下 backup-session-*.jsonl.zstd
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
已知真值表:事故修复后会话 PASS;`seqgap`/`corrupt`/`rewritten-230542` FAIL;`spliced-orphan` PASS(持久化层合法——#3632 的"消费路径判不可读"属于另一类契约,本工具只守护持久化契约层,见 [docs/CONTRACTS.md](docs/CONTRACTS.md) 边界说明)。
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 与三件套的关系
|
|
143
|
+
|
|
144
|
+
| 工具 | 象限 | 状态 |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| [workbuddy-session-fork](https://github.com/yamingmou/workbuddy-session-fork) | 会话分叉 · 状态管理 | ✅ 已发布 v1.2.0 |
|
|
147
|
+
| **dsh-log-contract**(本仓库) | 日志契约 · 接口稳定性 | 🆕 Phase 1 CLI 离线体检 + 写前校验 |
|
|
148
|
+
| dsh-turn-guard(规划中) | 中断回合 · 异常韧性 | 待立项 |
|
|
149
|
+
|
|
150
|
+
三者共享同一份 DSH 日志事件契约认知(59 条审计发现 = spec,aborted/corrupt/seqgap 化石 = 测试集)。dsh-retrace(回溯时间线)可把本工具的违规标记渲染到时间线上;本工具是 retrace 投影源健康度的**前置保险**。
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## Roadmap
|
|
155
|
+
|
|
156
|
+
- [x] **Phase 1(本版 0.1.0)**:CLI 离线体检 + 写前校验 + 契约目录
|
|
157
|
+
- [ ] Phase 1.5:违规报告的 `--fix` 建议(seq 缺口修复复用 `fix-seq-gap.mjs` 方法论)、CI 集成(`dsh-log-contract check` 作为 Harness 会话目录的定时守护)
|
|
158
|
+
- [ ] Phase 2:运行时守护(订阅 session append 事件流实时校验,断裂即标记 `dsh/contract-violation` 事件,策略可配 告警/拦截)——DSH 插件形态
|
|
159
|
+
- [ ] Phase 3:与 dsh-turn-guard / dsh-retrace 时间线联动
|
|
160
|
+
|
|
161
|
+
## 许可
|
|
162
|
+
|
|
163
|
+
MIT © OfferKuai Team
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-log-contract · bin/dsh-log-contract.mjs
|
|
4
|
+
*
|
|
5
|
+
* CLI:日志契约守护(DSH session log contract guard)。
|
|
6
|
+
*
|
|
7
|
+
* 子命令:
|
|
8
|
+
* check <session-log> 离线体检:解码 + 全契约校验 + 违规报告
|
|
9
|
+
* (支持 .jsonl / .jsonl.zstd)
|
|
10
|
+
* prewrite <edit-file> --log <session-log>
|
|
11
|
+
* 写前校验:edit 文件描述一次"拟写入",
|
|
12
|
+
* 在落盘前用三层契约判定 通过/拒绝
|
|
13
|
+
* contracts 列出内置契约规则目录
|
|
14
|
+
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import { loadSessionLog, validateSessionLog, createPreWriter, CONTRACT_RULES, ruleById } from '../lib/index.js';
|
|
17
|
+
|
|
18
|
+
const USAGE = `dsh-log-contract —— 日志契约守护(DSH session log contract guard)
|
|
19
|
+
|
|
20
|
+
用法:
|
|
21
|
+
dsh-log-contract check <session-log> [--json] [--max-details N]
|
|
22
|
+
离线体检。session-log 支持 .jsonl 与 .jsonl.zstd。
|
|
23
|
+
--json 输出机器可读 JSON 报告
|
|
24
|
+
--max-details N 每条违规最多列 N 个缺失 seq(默认 8,--json 忽略)
|
|
25
|
+
|
|
26
|
+
dsh-log-contract prewrite <edit-file> --log <session-log> [--json]
|
|
27
|
+
写前校验。edit-file 为 JSON,两种形状:
|
|
28
|
+
{ "append": { ...事件... } } 拟追加一个事件到日志尾部
|
|
29
|
+
{ "edit": [ ...事件列表... ] } 帧级手术后的完整事件列表
|
|
30
|
+
判定通过/拒绝并列出全部违规(三层契约:持久化/引擎/插件)。
|
|
31
|
+
|
|
32
|
+
dsh-log-contract contracts
|
|
33
|
+
列出内置契约规则目录(含官方源码出处)。
|
|
34
|
+
|
|
35
|
+
dsh-log-contract --version / --help
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
function fail(message, code = 1) {
|
|
39
|
+
process.stderr.write(`${message}\n`);
|
|
40
|
+
process.exit(code);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function printViolations(violations, maxDetails = 8) {
|
|
44
|
+
if (violations.length === 0) {
|
|
45
|
+
process.stdout.write(' ✔ 无违规\n');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const v of violations) {
|
|
49
|
+
const loc = [v.seq !== null ? `seq ${v.seq}` : null, v.lineNo !== null ? `line ${v.lineNo}` : null]
|
|
50
|
+
.filter(Boolean).join(' / ');
|
|
51
|
+
const head = ` [${v.severity}] ${v.id} ${loc ? `@ ${loc}` : ''}${v.eventType ? ` (${v.eventType})` : ''}`;
|
|
52
|
+
process.stdout.write(`${head}\n ${v.message}\n`);
|
|
53
|
+
if (Array.isArray(v.missingSeqs) && v.missingSeqs.length > maxDetails) {
|
|
54
|
+
process.stdout.write(` …另有 ${v.missingSeqs.length - maxDetails} 个缺失 seq 未列出\n`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cmdCheck(args) {
|
|
60
|
+
const json = args.includes('--json');
|
|
61
|
+
const maxDetailsIdx = args.indexOf('--max-details');
|
|
62
|
+
const maxDetails = maxDetailsIdx >= 0 && args[maxDetailsIdx + 1] ? Number(args[maxDetailsIdx + 1]) : 8;
|
|
63
|
+
const file = args.find((a) => !a.startsWith('-'));
|
|
64
|
+
if (!file) fail(USAGE);
|
|
65
|
+
|
|
66
|
+
let log;
|
|
67
|
+
try {
|
|
68
|
+
log = loadSessionLog(file);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
fail(`读取失败:${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
const result = validateSessionLog(log);
|
|
73
|
+
const { summary, violations, ok } = result;
|
|
74
|
+
|
|
75
|
+
if (json) {
|
|
76
|
+
process.stdout.write(JSON.stringify({ file, ok, summary, violations }, null, 2) + '\n');
|
|
77
|
+
process.exit(ok ? 0 : 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
process.stdout.write(`\n📋 dsh-log-contract check —— ${file}\n`);
|
|
81
|
+
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`);
|
|
82
|
+
process.stdout.write(` 违规 ${summary.total}(error ${summary.bySeverity.error} / warning ${summary.bySeverity.warning})\n\n`);
|
|
83
|
+
printViolations(violations, maxDetails);
|
|
84
|
+
process.stdout.write(`\n${ok ? '✅ 通过:官方 foldSurface 可重放,三层契约绿' : '❌ 未通过:见上方违规明细(error 级 = 会话不可读/不可写)'}\n\n`);
|
|
85
|
+
process.exit(ok ? 0 : 1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function cmdPrewrite(args) {
|
|
89
|
+
const json = args.includes('--json');
|
|
90
|
+
const logIdx = args.indexOf('--log');
|
|
91
|
+
const file = args.find((a) => !a.startsWith('-') && a !== 'prewrite');
|
|
92
|
+
if (!file || logIdx < 0 || !args[logIdx + 1]) fail(USAGE);
|
|
93
|
+
|
|
94
|
+
let plan;
|
|
95
|
+
try {
|
|
96
|
+
plan = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
fail(`edit 文件读取/解析失败:${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof plan !== 'object' || plan === null) fail('edit 文件必须是 JSON 对象');
|
|
101
|
+
|
|
102
|
+
let log;
|
|
103
|
+
try {
|
|
104
|
+
log = loadSessionLog(args[logIdx + 1]);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fail(`会话日志读取失败:${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
const baseline = validateSessionLog(log);
|
|
109
|
+
if (!baseline.ok) {
|
|
110
|
+
// 基线已坏:写前校验无法在坏基线上给出可信结论
|
|
111
|
+
fail(`基线会话已有 ${baseline.summary.bySeverity.error} 个 error 级违规,请先修复基线再校验写入(安全修复协议第 2 步:改前基线必须绿)`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const prewriter = createPreWriter({ events: log.events.map((e) => e.event) });
|
|
115
|
+
let result;
|
|
116
|
+
if (Object.hasOwn(plan, 'append')) {
|
|
117
|
+
result = prewriter.validateAppend(plan.append);
|
|
118
|
+
result.op = 'append';
|
|
119
|
+
} else if (Object.hasOwn(plan, 'edit')) {
|
|
120
|
+
if (!Array.isArray(plan.edit)) fail('edit 必须为事件数组');
|
|
121
|
+
result = prewriter.validateEdit(plan.edit);
|
|
122
|
+
result.op = 'edit';
|
|
123
|
+
} else {
|
|
124
|
+
fail('edit 文件必须含 "append" 或 "edit" 键');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (json) {
|
|
128
|
+
process.stdout.write(JSON.stringify({ file, op: result.op, ok: result.ok, bySeverity: result.bySeverity, violations: result.violations }, null, 2) + '\n');
|
|
129
|
+
process.exit(result.ok ? 0 : 1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
process.stdout.write(`\n✍️ dsh-log-contract prewrite —— ${file}(op: ${result.op},nextSeq: ${prewriter.nextSeq})\n\n`);
|
|
133
|
+
if (result.ok) {
|
|
134
|
+
process.stdout.write(' ✅ 写入安全:三层契约全绿(持久化 foldSurface 可重放 / 引擎层无崩溃风险 / 插件 marker 语义自洽)\n');
|
|
135
|
+
process.stdout.write(` 写入后 surface 节点 ${result.stateAfter.surfaceNodes} 个,nextSeq ${result.stateAfter.nextSeq}\n\n`);
|
|
136
|
+
process.exit(0);
|
|
137
|
+
}
|
|
138
|
+
process.stdout.write(' ❌ 写入会被拒:\n');
|
|
139
|
+
printViolations(result.violations);
|
|
140
|
+
process.stdout.write('\n');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function cmdContracts() {
|
|
145
|
+
process.stdout.write('dsh-log-contract 契约规则目录(spec:59 条审计发现 + 官方源码逐行核对)\n\n');
|
|
146
|
+
for (const r of CONTRACT_RULES) {
|
|
147
|
+
process.stdout.write(` ${r.id} [${r.severity}/${r.layer}] ${r.title}\n ${r.description}\n 出处: ${r.source}\n\n`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const args = process.argv.slice(2);
|
|
152
|
+
const cmd = args[0];
|
|
153
|
+
if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
154
|
+
process.stdout.write(USAGE);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
157
|
+
if (cmd === '--version' || cmd === '-v') {
|
|
158
|
+
const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
159
|
+
process.stdout.write(`dsh-log-contract ${pkg.version}\n`);
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
if (cmd === 'check') cmdCheck(args.slice(1));
|
|
163
|
+
else if (cmd === 'prewrite') cmdPrewrite(args.slice(1));
|
|
164
|
+
else if (cmd === 'contracts') cmdContracts();
|
|
165
|
+
else fail(`未知子命令 "${cmd}"\n\n${USAGE}`);
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# 契约规则目录(CONTRACTS)
|
|
2
|
+
|
|
3
|
+
> DSH 会话日志契约的**可执行 spec**。每条规则在 `lib/checks.js`(逐事件判定)
|
|
4
|
+
> 与 `lib/prewrite.js`(写前校验)中有对应实现;离线体检(`lib/validate.js`)
|
|
5
|
+
> 逐条执行并在最后用官方 `foldSurface` 终验(S8)。
|
|
6
|
+
>
|
|
7
|
+
> 规则来源:`dsh-scale-audit-疑点记录.md`(59 条审计发现)+ `复盘-会话修复事故-20260825.md`
|
|
8
|
+
> (三层契约)+ `@deepseek-ai/dsh-session@0.1.0-rc.7` 官方源码逐行核对。
|
|
9
|
+
>
|
|
10
|
+
> 严重度:**error** = 违反即会话不可加载/写入被拒(fail-loud);**warning** = 合法但可疑。
|
|
11
|
+
|
|
12
|
+
## 规则索引
|
|
13
|
+
|
|
14
|
+
| id | 严重度 | 层级 | 规则 |
|
|
15
|
+
|---|---|---|---|
|
|
16
|
+
| H1 | error | persistence | 首行为合法 JSON 且 type=session |
|
|
17
|
+
| H2 | error | persistence | header 版本与必填字段 |
|
|
18
|
+
| R1 | error | persistence | 每行必须是合法 JSON |
|
|
19
|
+
| R2 | error | persistence | chunk 行必须满足精确信封形状 |
|
|
20
|
+
| R3 | error | persistence | chunk 行展开后成员 seq/time 安全 |
|
|
21
|
+
| E1 | error | persistence | 每个事件携带非负安全整数 seq |
|
|
22
|
+
| E2 | error | persistence | seq 严格连续(单写入者假设) |
|
|
23
|
+
| E3 | error | persistence | type 必须在已知词汇表内(或带 ignorable 标记) |
|
|
24
|
+
| E4 | error | persistence | data 与 surface 元数据必须 JSON 无损 |
|
|
25
|
+
| E5 | error | persistence | 禁用遗留词汇 |
|
|
26
|
+
| E6 | error | persistence | 消息类事件消息形状 |
|
|
27
|
+
| S1 | error | persistence | surface 候选类型必须携带 surfaceOp |
|
|
28
|
+
| S2 | error | persistence | 非 surface 类型不得携带 surface 元数据 |
|
|
29
|
+
| S3 | error | persistence | append 的 sourceEventSeqs 契约 |
|
|
30
|
+
| S4 | error | persistence | replace 操作数与范围合法性 |
|
|
31
|
+
| S5 | error | persistence | ★ replace 的 sourceEventSeqs 必须完整覆盖被替换节点 |
|
|
32
|
+
| S6 | error | persistence | sourceEventSeqs 自身约束 |
|
|
33
|
+
| S7 | error | persistence | tool/result 替换仅允许单节点内容改写 |
|
|
34
|
+
| S8 | error | persistence | 整日志 foldSurface 可重放(终验) |
|
|
35
|
+
| M1 | error | engine | turn/step 为 null 的 assistant/message 只能 replace,不能 append |
|
|
36
|
+
| P1 | warning | plugin | marker id 前缀必须被识别 |
|
|
37
|
+
| P2 | error | plugin | marker 自身 seq 不得出现在自身 shadowed 集 |
|
|
38
|
+
| C1 | warning | concurrency | seq 缺口/倒退提示多写入者 |
|
|
39
|
+
| Z1 | warning | framing | zstd 尾帧撕裂 |
|
|
40
|
+
| Z2 | error | framing | zstd 帧解码失败 = 单帧全损 |
|
|
41
|
+
|
|
42
|
+
## 详细规则
|
|
43
|
+
|
|
44
|
+
### H1 — 首行为合法 JSON 且 type=session
|
|
45
|
+
|
|
46
|
+
- **层级**: persistence | **严重度**: error
|
|
47
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1109-1126 (validateSessionHeader)
|
|
48
|
+
- **契约**: 会话日志首行必须是可 JSON.parse 的对象,且 type 为 "session"。首行损坏 = 整个会话不可读。
|
|
49
|
+
|
|
50
|
+
### H2 — header 版本与必填字段
|
|
51
|
+
|
|
52
|
+
- **层级**: persistence | **严重度**: error
|
|
53
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1110-1125
|
|
54
|
+
- **契约**: header.version 必须为 0;id 为字符串;createdAt 为非负安全整数;cwd 若存在必须为绝对路径;origin 只能为 "subagent"。
|
|
55
|
+
|
|
56
|
+
### R1 — 每行必须是合法 JSON
|
|
57
|
+
|
|
58
|
+
- **层级**: persistence | **严重度**: error
|
|
59
|
+
- **出处**: 审计方法论(scan-seq-gaps.mjs);dsh-session-persistence-jsonl 读路径
|
|
60
|
+
- **契约**: 非空行无法 JSON.parse = 损坏行。帧边界产生的空行是合法的(跳过)。
|
|
61
|
+
|
|
62
|
+
### R2 — chunk 行必须满足精确信封形状
|
|
63
|
+
|
|
64
|
+
- **层级**: persistence | **严重度**: error
|
|
65
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:922-971 (validateRow)
|
|
66
|
+
- **契约**: text-chunks / reasoning-chunks / tool-call-chunks 行必须精确为 {type, seq0, time0, data},data 精确为 {turn, step, index, dt, texts|args}。损坏 = 整段 run 丢失且加载失败(fail-loud,无跳过逃生舱)。
|
|
67
|
+
|
|
68
|
+
### R3 — chunk 行展开后成员 seq/time 安全
|
|
69
|
+
|
|
70
|
+
- **层级**: persistence | **严重度**: error
|
|
71
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:964-969
|
|
72
|
+
- **契约**: 展开后成员 seq 与 time 必须保持安全整数(seq0+len-1 与逐 gap 累加的 time 不溢出)。
|
|
73
|
+
|
|
74
|
+
### E1 — 每个事件携带非负安全整数 seq
|
|
75
|
+
|
|
76
|
+
- **层级**: persistence | **严重度**: error
|
|
77
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:295-298 (isEventSeq)、:1453-1459 (append 信封)
|
|
78
|
+
- **契约**: 事件信封为 {type, seq, time, data, ...surfaceMetadata};seq 必须是非负安全整数。
|
|
79
|
+
|
|
80
|
+
### E2 — seq 严格连续(单写入者假设)
|
|
81
|
+
|
|
82
|
+
- **层级**: persistence | **严重度**: error
|
|
83
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:398 (planSurfaceEvent "not contiguous");审计 S2/N6
|
|
84
|
+
- **契约**: seq 必须从 0(或窗口 baseSeq)严格连续递增。缺口/倒退 = 违反单写入者假设(多实例共享存储并发写的痕迹),加载时直接 throw。
|
|
85
|
+
|
|
86
|
+
### E3 — type 必须在已知词汇表内(或带 ignorable 标记)
|
|
87
|
+
|
|
88
|
+
- **层级**: persistence | **严重度**: error
|
|
89
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1046-1049 (KNOWN_SESSION_EVENT_TYPES 注释)
|
|
90
|
+
- **契约**: 词汇表外的 type 会被持久化读路径拒绝,除非事件带信封级 ignorable 标记(新版本 harness 写入的日志)。插件事件(如 retrace marker 以 assistant/message 承载)不在词汇表外——它们复用核心类型。
|
|
91
|
+
|
|
92
|
+
### E4 — data 与 surface 元数据必须 JSON 无损
|
|
93
|
+
|
|
94
|
+
- **层级**: persistence | **严重度**: error
|
|
95
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1446-1450 (snapshotJsonValue 双快照)
|
|
96
|
+
- **契约**: append 热路径对 data 与 surfaceMetadata 各做一次 lossless-JSON 全量校验;非 JSON 安全值(函数/循环引用/非有限数)写入前即被拒。
|
|
97
|
+
|
|
98
|
+
### E5 — 禁用遗留词汇
|
|
99
|
+
|
|
100
|
+
- **层级**: persistence | **严重度**: error
|
|
101
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1273-1277 (assertSupportedRequestHeader)
|
|
102
|
+
- **契约**: request/header-delta 与 reason=fallback 的 request/header 是已删除的遗留格式,写入即被拒。
|
|
103
|
+
|
|
104
|
+
### E6 — 消息类事件消息形状
|
|
105
|
+
|
|
106
|
+
- **层级**: persistence | **严重度**: error
|
|
107
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:1242-1266 (assertMessageEventShape)
|
|
108
|
+
- **契约**: user/message、assistant/message、tool/result 必须携带具名 message(非空 id、正确 role、合法 source、content 数组;assistant 需 model source,tool/result 需 tool source 且 toolCallId 匹配)。
|
|
109
|
+
|
|
110
|
+
### S1 — surface 候选类型必须携带 surfaceOp
|
|
111
|
+
|
|
112
|
+
- **层级**: persistence | **严重度**: error
|
|
113
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:312-317 (surfaceOpOf)
|
|
114
|
+
- **契约**: user/message、assistant/message、tool/result 是 surface-eligible 类型,缺 surfaceOp 即违反契约。
|
|
115
|
+
|
|
116
|
+
### S2 — 非 surface 类型不得携带 surface 元数据
|
|
117
|
+
|
|
118
|
+
- **层级**: persistence | **严重度**: error
|
|
119
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:307-311 (surfaceOpOf)
|
|
120
|
+
- **契约**: 词汇表内非 surface-eligible 类型带 surfaceOp / sourceEventSeqs = 违反契约。
|
|
121
|
+
|
|
122
|
+
### S3 — append 的 sourceEventSeqs 契约
|
|
123
|
+
|
|
124
|
+
- **层级**: persistence | **严重度**: error
|
|
125
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:401-407、:320-337 (assertProvenance)
|
|
126
|
+
- **契约**: append 以空 shadowed 集校验:sourceEventSeqs 若携带必须满足 assertProvenance(数组、无重复、全部引用更早事件);任何违规即写入被拒。
|
|
127
|
+
|
|
128
|
+
### S4 — replace 操作数与范围合法性
|
|
129
|
+
|
|
130
|
+
- **层级**: persistence | **严重度**: error
|
|
131
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:300-303 (isReplaceOp)、:339-350 (replacementRange)
|
|
132
|
+
- **契约**: replace 必须是精确的 {op:"replace", start, end};start/end 必须存在于当前 surface 节点且 startIdx ≤ endIdx。
|
|
133
|
+
|
|
134
|
+
### S5 — replace 的 sourceEventSeqs 必须完整覆盖被替换节点
|
|
135
|
+
|
|
136
|
+
- **层级**: persistence | **严重度**: error
|
|
137
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:335-336 (assertProvenance);复盘事故第 1 轮
|
|
138
|
+
- **契约**: ★ 写前校验核心规则:sourceEventSeqs 必须包含每一个被替换(shadowed)的 surface 节点,缺一个 = 会话加载被拒(SessionPersistenceCorruptionError)。2026-08-25 事故第 1 轮(清空 sourceEventSeqs)正是违反此规则。
|
|
139
|
+
|
|
140
|
+
### S6 — sourceEventSeqs 自身约束
|
|
141
|
+
|
|
142
|
+
- **层级**: persistence | **严重度**: error
|
|
143
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:320-333 (assertProvenance)
|
|
144
|
+
- **契约**: sourceEventSeqs 存在时必须为数组、无重复、全部引用更早事件(< 当前 seq),且除 assistant/message 外不得为空。
|
|
145
|
+
|
|
146
|
+
### S7 — tool/result 替换仅允许单节点内容改写
|
|
147
|
+
|
|
148
|
+
- **层级**: persistence | **严重度**: error
|
|
149
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:369-395 (assertToolResultRewrite)
|
|
150
|
+
- **契约**: tool/result 的 replace 必须恰好重写 1 个当前节点、目标是 tool/result,且除 message.content 外不得改动任何字段。
|
|
151
|
+
|
|
152
|
+
### S8 — 整日志 foldSurface 可重放
|
|
153
|
+
|
|
154
|
+
- **层级**: persistence | **严重度**: error
|
|
155
|
+
- **出处**: @deepseek-ai/dsh-session lib/index.js:444-455 (foldSurface);复盘"官方 foldSurface 不抛 = 通过"
|
|
156
|
+
- **契约**: 终验:把全部事件按序喂给官方 foldSurface,不抛 = 持久化层通过。S1–S7 任何一条违反都会在此暴露。
|
|
157
|
+
|
|
158
|
+
### M1 — turn/step 为 null 的 assistant/message 只能 replace,不能 append
|
|
159
|
+
|
|
160
|
+
- **层级**: engine | **严重度**: error
|
|
161
|
+
- **出处**: 复盘事故第 2 轮(rt.js:6816 崩溃);实证 data.turn/data.step:正常消息为数字、插件 marker 为 null
|
|
162
|
+
- **契约**: data.turn/data.step 为 null 的 assistant/message(如插件 marker)只能以 replace 承载(走插件 marker 定义);作为 append 会落进核心 assistant-step 定义,因 turn=null 发布 location data 导致客户端引擎崩溃。
|
|
163
|
+
|
|
164
|
+
### P1 — marker id 前缀必须被识别
|
|
165
|
+
|
|
166
|
+
- **层级**: plugin | **严重度**: warning
|
|
167
|
+
- **出处**: retrace 插件 RENAME RULE(lib/client.js:29-44);复盘事故
|
|
168
|
+
- **契约**: assistant/message 替换事件的 message.id 以 retrace- / message-editor- 为已知前缀。未知前缀 = 改名后未登记遗留前缀,旧 marker 的隐藏语义会断裂(软兼容丢失)。
|
|
169
|
+
|
|
170
|
+
### P2 — marker 自身 seq 不得出现在自身 shadowed 集
|
|
171
|
+
|
|
172
|
+
- **层级**: plugin | **严重度**: error
|
|
173
|
+
- **出处**: retrace 插件 lib/client.js:393("event and never a surface node")
|
|
174
|
+
- **契约**: marker 的 sourceEventSeqs(= shadowedSeqs,驱动 CSS 隐藏)不得包含 marker 自身 seq——marker 节点由隐藏逻辑跳过,出现在 shadowed 集属于自指语义错误。
|
|
175
|
+
|
|
176
|
+
### C1 — seq 缺口/倒退提示多写入者
|
|
177
|
+
|
|
178
|
+
- **层级**: concurrency | **严重度**: warning
|
|
179
|
+
- **出处**: 审计 N6:dsh-session-persistence-jsonl appendLines 无锁(:1200-1227),全仓无会话级排他锁
|
|
180
|
+
- **契约**: 离线体检无法直接观测跨进程竞态,但 E2 暴露的缺口/倒退即是"≥2 个 Host 进程共享同一 session 目录并发写"的后果。单实例部署不触发。
|
|
181
|
+
|
|
182
|
+
### Z1 — zstd 尾帧撕裂
|
|
183
|
+
|
|
184
|
+
- **层级**: framing | **严重度**: warning
|
|
185
|
+
- **出处**: 审计 N5 相关;帧扫描方法论
|
|
186
|
+
- **契约**: 尾帧不完整(torn):可能正在写入(in-flight)或文件被截断。若这是唯一异常,通常可等待写入完成;若持续存在则是截断证据。
|
|
187
|
+
|
|
188
|
+
### Z2 — zstd 帧解码失败 = 单帧全损
|
|
189
|
+
|
|
190
|
+
- **层级**: framing | **严重度**: error
|
|
191
|
+
- **出处**: 审计 N5:多帧单帧全损 → 整会话不可读
|
|
192
|
+
- **契约**: 任一帧解码失败(磁盘 bitrot / 传输截断 / 并发写撕裂)即整会话不可读;帧越多,单帧损坏下丢失概率线性上升。
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## 边界说明(诚实声明)
|
|
197
|
+
|
|
198
|
+
1. **本工具守护"持久化契约层"**。`#3632` 的"one log, two consumers, two verdicts"中,
|
|
199
|
+
`agent/inbox/spliced` 孤儿在持久化层**是合法的**(官方 `foldSurface` 可通过)——
|
|
200
|
+
违规发生在**消费路径**(`sessionQuery`/UI 判不可读)。本工具的 `check` 会如实报 PASS,
|
|
201
|
+
不冒充能判消费路径契约;该层契约属另一类问题(可配合 dsh-retrace / 上游修复)。
|
|
202
|
+
2. **M1 是引擎层启发式规则**:`data.turn/step` 为 null 的 `assistant/message` 以 append 进入
|
|
203
|
+
surface 会触发客户端引擎崩溃(rt.js:6816)——依据是 2026-08-25 事故第 2 轮实证。
|
|
204
|
+
离线场景无法渲染客户端,故以 error 级保守拦截,避免事故重演。
|
|
205
|
+
3. **写前校验以"官方 foldSurface 不抛"为最终权威**:逐事件归因(S1–S7)负责定位,
|
|
206
|
+
官方重放(S8)负责背书;两套都绿才算通过。若官方实现更新导致判定漂移,
|
|
207
|
+
以官方为准并更新本 spec(本工具自己就是契约漂移的哨兵)。
|
|
208
|
+
4. **seq 严格连续是单写入者假设**(N6):离线 `check` 只能看到缺口/倒退的结果,
|
|
209
|
+
无法观测竞态本身;`C1` 给出解释性告警而非臆断。
|