dsh-log-contract 0.3.14 → 0.3.16
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 +53 -39
- package/README.zh.md +35 -31
- package/bin/dsh-log-contract.mjs +17 -17
- package/docs/CONTRACTS.md +19 -18
- package/lib/archaeology.js +2 -2
- package/lib/checks.js +19 -19
- package/lib/contracts.js +24 -25
- package/lib/host-probes.js +3 -3
- package/lib/log-reader.js +5 -6
- package/lib/prewrite.js +8 -8
- package/lib/repair.js +13 -15
- package/lib/validate.js +15 -15
- package/lib/version-support.js +1 -1
- package/lib/vocab.js +4 -4
- package/package.json +3 -3
package/lib/repair.js
CHANGED
|
@@ -209,7 +209,7 @@ export function neutralizeMarkersText(text, { onlyLegacy = false } = {}) {
|
|
|
209
209
|
if (v.data?.turn != null || v.data?.step != null) continue;
|
|
210
210
|
const id = v.data?.message?.id;
|
|
211
211
|
if (typeof id !== 'string' || !MARKER_PREFIXES.some((p) => id.startsWith(`${p}-`))) continue;
|
|
212
|
-
//
|
|
212
|
+
// 一次性根治路径(`--neutralize-legacy-markers`):只动**历史载体**
|
|
213
213
|
// (`assistant/message` + `data.editor`);新载体是 `user/message` + `data.id`,
|
|
214
214
|
// 本来就不在这个分支里(类型不符),这里额外的判据是"必须有 editor",
|
|
215
215
|
// 便于把"清历史债"与"泛化中和"区分开、也让报告口径可核对。
|
|
@@ -423,15 +423,14 @@ function renumberWithDrops(parts, dropPredicate, dropLinePredicate = null) {
|
|
|
423
423
|
*
|
|
424
424
|
* ⚠️ 与 renumberWithDrops 不同:本函数按「行是否保留」决定删除(双流交织时
|
|
425
425
|
* 保留行与删除行可能共享 seq 值,按 seq 值判删会误删保留行——2026-08-31
|
|
426
|
-
*
|
|
427
|
-
* keep-ranges.mjs 的 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
|
|
426
|
+
* 实测复现),且保留行**全量重映射** seq 从 0 连续(与 `--keep-ranges` 同一 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
|
|
428
427
|
* surfaceOp 范围。header 行(首行)恒保留且不改。
|
|
429
428
|
* @param {string[]} parts - 按 \n 切分的行数组。
|
|
430
429
|
* @param {(lineIdx: number) => boolean} keepLinePredicate - 行身份保留判定。
|
|
431
430
|
* @returns {{ text: string, removed: number, renumbered: number }}
|
|
432
431
|
*/
|
|
433
432
|
function renumberKeptLines(parts, keepLinePredicate) {
|
|
434
|
-
// 第一遍:按物理序收集保留行的事件 seq(chunk
|
|
433
|
+
// 第一遍:按物理序收集保留行的事件 seq(chunk 行展开,与 --keep-ranges 同口径)
|
|
435
434
|
const keptLineIdx = [];
|
|
436
435
|
for (let i = 0; i < parts.length; i++) {
|
|
437
436
|
const raw = parts[i];
|
|
@@ -559,7 +558,7 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
559
558
|
const parts = text.split('\n');
|
|
560
559
|
const out = [];
|
|
561
560
|
let changed = 0;
|
|
562
|
-
// ── seq 引用平移(2026-09-14
|
|
561
|
+
// ── seq 引用平移(2026-09-14:并补两处同族缺口)──
|
|
563
562
|
// 一手依据(官方 v0→v1 校验,@0.1.5-rc.2):
|
|
564
563
|
// - `data.shadowedRange{start,end}` + `data.shadowedSeqs`:`dsh-session-format-v0-to-v1`
|
|
565
564
|
// `lib/index.js:55-70`(compaction/prune、compaction/summary 的 dispositions)、
|
|
@@ -655,7 +654,7 @@ export function tailRenumberText(text, startSeq, delta) {
|
|
|
655
654
|
* 重新排队 → UI 显示"待排队消息")。与 neutralize 同类:原地改
|
|
656
655
|
* `removedCount → 0`(start/inserted 不变,seq/行数不变 → 附着力安全)。
|
|
657
656
|
*
|
|
658
|
-
* ⚠️ 判定(2026-08-31
|
|
657
|
+
* ⚠️ 判定(2026-08-31 修正):不能对"所有 removedCount>0 的
|
|
659
658
|
* next-turn spliced"下手——DSH 每个轮次消费消息都会写这种 spliced
|
|
660
659
|
* (archive 实测 3069 处 removedCount>0,但真 I1 违规只 2 处)。只归零
|
|
661
660
|
* **实际触发 I1 违规**的那条:按官方 inbox 重放(checks.js
|
|
@@ -724,10 +723,10 @@ export function neutralizeOrphanText(text) {
|
|
|
724
723
|
export function extractTurnText(text, keepTurn, secondTurnTo = null) {
|
|
725
724
|
const parts = text.split('\n');
|
|
726
725
|
const KEEP_NULL_TYPES = new Set(['agent/inbox/spliced', 'user/message', 'request/header', 'session/end-seed', 'command/run', 'command/done']);
|
|
727
|
-
//
|
|
726
|
+
// 第一遍:按行身份决定去留(保留 turn===keepTurn 的行 + 无 turn 系统事件
|
|
728
727
|
// 白名单),并就地改第二个同名轮次的 turn 号。
|
|
729
|
-
// ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq
|
|
730
|
-
//
|
|
728
|
+
// ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq(实测
|
|
729
|
+
// 复现内容静默丢失)——按行身份删除才安全。
|
|
731
730
|
const dropLines = new Set();
|
|
732
731
|
let sawFirstTurnStart = false;
|
|
733
732
|
let inRenumberTurn = false;
|
|
@@ -769,8 +768,7 @@ export function extractTurnText(text, keepTurn, secondTurnTo = null) {
|
|
|
769
768
|
}
|
|
770
769
|
|
|
771
770
|
/**
|
|
772
|
-
* 只保留指定 seq 区间,其余删除 +
|
|
773
|
-
* 源:tools/keep-ranges.mjs)。
|
|
771
|
+
* 只保留指定 seq 区间,其余删除 + 全量重编号(`--keep-ranges`)。
|
|
774
772
|
*
|
|
775
773
|
* 从交织/污染文件中提取干净区段。区间为 1-based 行号(含端点),
|
|
776
774
|
* 如 "10-20,40-50";区段外的行丢弃。header 行(首行)永远保留。
|
|
@@ -788,7 +786,7 @@ export function keepRangesText(text, rangesSpec) {
|
|
|
788
786
|
return [a, b];
|
|
789
787
|
});
|
|
790
788
|
const parts = text.split('\n');
|
|
791
|
-
// 按行身份删除(双流交织时保留行与删除行可能共享 seq
|
|
789
|
+
// 按行身份删除(双流交织时保留行与删除行可能共享 seq——实测发现)
|
|
792
790
|
const dropLines = new Set();
|
|
793
791
|
let keptLines = 0;
|
|
794
792
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -1158,7 +1156,7 @@ export function repairSession(file, opts = {}) {
|
|
|
1158
1156
|
let plain;
|
|
1159
1157
|
if (isZstd) {
|
|
1160
1158
|
try {
|
|
1161
|
-
//
|
|
1159
|
+
// 边界:**写路径不吃撕裂尾帧**。`check`(只读)按宿主语义恢复撕裂尾帧;但
|
|
1162
1160
|
// `fix` 会回写文件,若尾帧是**活动会话正在写入**的部分,回写会把它截掉 ——
|
|
1163
1161
|
// 与宿主"由持有租约的会话自己截断"(:226-228)不同责。故这里显式 strict。
|
|
1164
1162
|
plain = decompressZstd(buf, { allowTorn: false }).toString('utf8');
|
|
@@ -1245,10 +1243,10 @@ export function repairSession(file, opts = {}) {
|
|
|
1245
1243
|
issues.push({ kind: 'trim-budget', detail: `估算 ${r.estimatedTokens} tokens ≤ 预算 ${opts.trimBudget}——无需裁剪(${r.kept} 条消息全保留)` });
|
|
1246
1244
|
}
|
|
1247
1245
|
}
|
|
1248
|
-
// L4 新原语(2026-08-30
|
|
1246
|
+
// L4 新原语(2026-08-30 收编外部验证工具)
|
|
1249
1247
|
if (typeof opts.tailRenumberDelta === 'number') {
|
|
1250
1248
|
// 起点自动推导:从首个可平移的 seq 起(即所有事件都平移)。
|
|
1251
|
-
// fix-tail 原工具是 <startLine> <delta>
|
|
1249
|
+
// fix-tail 原工具是 <startLine> <delta> 双参;L4 简化为单参 delta
|
|
1252
1250
|
// (尾部全部平移)。若需部分平移,传 --tail-renumber 前先 --keep-ranges。
|
|
1253
1251
|
const r = tailRenumberText(plain, 0, opts.tailRenumberDelta);
|
|
1254
1252
|
if (r.error) {
|
package/lib/validate.js
CHANGED
|
@@ -60,14 +60,14 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
60
60
|
// C1 防御(多入口一致):即使调用方手搓 log 对象、未经 `loadSessionLog`,也在体检入口
|
|
61
61
|
// 归一一次 v3 区间编码。正常路径(loadSessionLog 已展开)下这是幂等的恒等映射。
|
|
62
62
|
const events = (log.events ?? []).map((e) => ({ ...e, event: normalizeEventSeqRanges(e.event) }));
|
|
63
|
-
//
|
|
63
|
+
// 词表/折叠路径按**被检文件自身版本**选择(不按运行时)。
|
|
64
64
|
// C2:版本是**本次调用的局部量**,显式传给每条按版本择路的判定——不再写模块级全局
|
|
65
65
|
// (旧实现 `setFileFormatVersion()` 会被同进程后调用的 prewrite 读到,造成结论翻转)。
|
|
66
66
|
const formatVersion = Number.isSafeInteger(header?.version) && header.version >= 0 ? header.version : 0;
|
|
67
67
|
|
|
68
68
|
// ── 宿主能力闸(S2·安全)──────────────────────────────────────────────────
|
|
69
69
|
// 被检文件版本 > 本宿主支持的最大文件版本 ⇒ 本宿主没有该版本的词表/折叠语义。继续按
|
|
70
|
-
//
|
|
70
|
+
// 本宿主语义判定会产出**假阳性**(实测:rc.7 上同一真实 v3 文件 →
|
|
71
71
|
// `S8×1 + E3×40 / verdict=broken / loadable:false`),危险是用户以为日志坏了去跑
|
|
72
72
|
// `fix --apply`。改为:只跑与版本无关的结构检查 + 显式"不可在本宿主评估"档。
|
|
73
73
|
if (!isAssessableFileVersion(formatVersion)) {
|
|
@@ -106,7 +106,7 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
// ── Z3 · 空文件体检(2026-09-09
|
|
109
|
+
// ── Z3 · 空文件体检(2026-09-09 T3 增量)──────────────────────────
|
|
110
110
|
// 空态无覆盖:36 条规则全来自"有内容"事故,空会话文件应显式报(不管 DSH 编排层怎么处理)。
|
|
111
111
|
// 有 header 但零事件 = 异常空会话(warning,不破坏 ok);连 header 都没有 → H1 已报。
|
|
112
112
|
if (header !== null && events.length === 0) {
|
|
@@ -159,7 +159,7 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
159
159
|
violations.push(...nullTurnStepViolations(events));
|
|
160
160
|
violations.push(...turnEndReasonViolations(events));
|
|
161
161
|
|
|
162
|
-
// ── E7 · ignorable 未知 type
|
|
162
|
+
// ── E7 · ignorable 未知 type 合法性(T2)────────────────────
|
|
163
163
|
violations.push(...ignorableTypeViolations(events, formatVersion));
|
|
164
164
|
|
|
165
165
|
// ── I1 · inbox seed 相对重放(fork 边界孤儿;交接书 L1)──────────────
|
|
@@ -225,7 +225,7 @@ export function validateSessionLog(log, opts = {}) {
|
|
|
225
225
|
// 版本支持面(2026-09-14 用户要求第 1 条):宿主版本 vs 测试基线 + 被检文件格式版本,
|
|
226
226
|
// 给出 warnings/readOnly。只读档由入口(prewrite/fix)强制执行。
|
|
227
227
|
result.support = detectSupport({ header, events });
|
|
228
|
-
//
|
|
228
|
+
// 漂移检测:行为探针(宿主自己的运行时函数当 oracle)+ 出处/前提漂移清单。
|
|
229
229
|
result.probes = hostProbes();
|
|
230
230
|
result.drift = driftOf(result.probes);
|
|
231
231
|
result.migration = migrationVerdict(result);
|
|
@@ -353,11 +353,11 @@ function hostProbes() {
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
/**
|
|
356
|
-
*
|
|
356
|
+
* 合并出的**漂移报告**(`check` 抬头与 `--json` 共用)。
|
|
357
357
|
* · `unverifiedRules` —— 行为探针在本宿主上**不成立**的规则(宿主语义与规则假设不符);
|
|
358
|
-
* · `driftedSources` —— 出处停在 rc.7、未在 0.1.5
|
|
359
|
-
* · `premiseStale` ——
|
|
360
|
-
* · `fixApplyBlocked` —— 只要任一非空,就**不得据此跑 `fix --apply
|
|
358
|
+
* · `driftedSources` —— 出处停在 rc.7、未在 0.1.5 上复核(机读清单);
|
|
359
|
+
* · `premiseStale` —— 判定前提已不成立/无法判定;
|
|
360
|
+
* · `fixApplyBlocked` —— 只要任一非空,就**不得据此跑 `fix --apply`**。
|
|
361
361
|
*/
|
|
362
362
|
export function driftOf(probes) {
|
|
363
363
|
const p = probes ?? hostProbes();
|
|
@@ -371,7 +371,7 @@ export function driftOf(probes) {
|
|
|
371
371
|
premiseStale,
|
|
372
372
|
undecidable: [...SOURCE_DRIFT.undecidable],
|
|
373
373
|
// `fix --apply` 的**硬前提**只看行为探针(宿主语义与规则假设不一致时才禁止写入类动作);
|
|
374
|
-
//
|
|
374
|
+
// 出处/前提清单是**已知文档债**,会显式告警但不阻断修复流程 —— 否则只要历史
|
|
375
375
|
// 清单非空,"修一个坏会话"这条主路径就永久不可用(见报告"我未照做之处")。
|
|
376
376
|
fixApplyBlocked: unverifiedRules.length > 0,
|
|
377
377
|
note: SOURCE_DRIFT.note,
|
|
@@ -397,11 +397,11 @@ export function migrationVerdict(result) {
|
|
|
397
397
|
'G3 v0 源:session/title 系列 messageSeqs 必须引用更早的人类 user/message(含"空 ⟺ 用户标题")',
|
|
398
398
|
],
|
|
399
399
|
uncovered: [
|
|
400
|
-
// 2026-09-15
|
|
400
|
+
// 2026-09-15:`turn/start` 试做后在**真实语料**上被判定为"应用对象错"——
|
|
401
401
|
// 官方状态机(assertReleasedArtifactRelationships)由 v1→v2 在**变换后的 v1/v2 artifact**
|
|
402
402
|
// 上调用(v1-to-v2/lib/index.js:104),带 cut/继承切点处理;在原始 v0 上照抄会在已 seed
|
|
403
403
|
// 的会话上狂报(实测样本:0.1.5 链并不以该规则拒它,原始 v0 上会报 19 条)。
|
|
404
|
-
// ⇒ 忠实复现需先做 v0→v1→v2
|
|
404
|
+
// ⇒ 忠实复现需先做 v0→v1→v2 变换;暂不做,保持未覆盖。
|
|
405
405
|
'turn/start 闭合/预期轮(官方 v1→v2 在**变换后** artifact 上判:does not close the prior turn / does not open expected turn)',
|
|
406
406
|
'assistant/attempt 配对/闭合(migration refuses the transformed artifact)',
|
|
407
407
|
'Session inheritedEventCut / 继承切点(官方在变换后 artifact 上按 cut 判;header 只有 seedLength)',
|
|
@@ -428,7 +428,7 @@ export function migrationVerdict(result) {
|
|
|
428
428
|
hostMaxFileVersion: HOST_MAX_FILE_VERSION,
|
|
429
429
|
blocked,
|
|
430
430
|
coverage,
|
|
431
|
-
// 2026-09-14
|
|
431
|
+
// 2026-09-14:待迁移文件(applies=true)**只**被 2 条 G 规则覆盖,
|
|
432
432
|
// 另有 6 类官方迁移规则未覆盖 ⇒ 评估范围只能是 "partial"。工具无法逐文件知道
|
|
433
433
|
// 本文件是否命中未覆盖类(那要先把 6 类实现出来或跑官方迁移),但它**知道**这件事。
|
|
434
434
|
assessmentScope: applies ? 'partial' : 'full',
|
|
@@ -488,7 +488,7 @@ export function resumeVerdict(result) {
|
|
|
488
488
|
violationsByTier: { structural: [], inbox: [], tokenMeter: [] },
|
|
489
489
|
};
|
|
490
490
|
}
|
|
491
|
-
//
|
|
491
|
+
// 三档各自的「阻断规则集」——按 L3 档位定义:
|
|
492
492
|
// 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2/T3/T4 的 error;
|
|
493
493
|
// 可继续:+ I1(inbox 重放);
|
|
494
494
|
// 可压缩:+ T1/T2/T3/T4(token-meter 配对 + 渲染层节点 key/turn)。
|
|
@@ -500,7 +500,7 @@ export function resumeVerdict(result) {
|
|
|
500
500
|
const has = (id) => (byId[id]?.length ?? 0) > 0;
|
|
501
501
|
|
|
502
502
|
// 可加载阻断 = 除 I1/T1/T2/T3/T4 外的所有 error 违规(结构/信封/物理序/工具配对等)。
|
|
503
|
-
// 注意:不能用 result.ok(它把 T1/T2/T3/T4/I1 也计为 error
|
|
503
|
+
// 注意:不能用 result.ok(它把 T1/T2/T3/T4/I1 也计为 error)——三档判定按 L3 定义,
|
|
504
504
|
// T1/T2/T3/T4 只影响「可压缩」档、I1 只影响「可继续」档。
|
|
505
505
|
const loadableBlockers = violations.filter(
|
|
506
506
|
(v) => v.severity === 'error' && !['I1', 'T1', 'T2', 'T3', 'T4', 'T5'].includes(v.id),
|
package/lib/version-support.js
CHANGED
|
@@ -104,7 +104,7 @@ export function detectSupport({ header, events, formatVersion, hostVersion: host
|
|
|
104
104
|
// ── 被检文件格式 ────────────────────────────────────────────────────────
|
|
105
105
|
const resolved = resolveFormatVersionDetailed({ formatVersion, header, events });
|
|
106
106
|
const unknownSource = resolved.source === 'default';
|
|
107
|
-
// S4
|
|
107
|
+
// S4(实测):无法识别格式时**状态标签必须是 `unverified`**(README 就是这么写的:
|
|
108
108
|
// "Format unrecognisable → same: "unverified format" + read-only")。旧实现让版本回落到默认 0
|
|
109
109
|
// ⇒ 标签打印成 `legacy`,与 README 不一致(`readOnly` 本来就对)。
|
|
110
110
|
const status = unknownSource ? 'unverified' : fileStatusOf(resolved.version);
|
package/lib/vocab.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-log-contract · lib/vocab.js ——
|
|
2
|
+
* dsh-log-contract · lib/vocab.js —— **按被检文件自身版本选词表与折叠路径**
|
|
3
3
|
*
|
|
4
4
|
* 背景(一手实测):同一健康会话,在**生产 0.1.1 解析**下 0 违规,在 **0.1.5 解析**下 3606 违规——
|
|
5
5
|
* 根因是本包原先用**运行时导出**的 `KNOWN_SESSION_EVENT_TYPES` / `foldSurface` 去判**旧格式(v0)文件**:
|
|
6
6
|
* 官方 0.1.5 的词汇表已不含 `assistant/chunk`(v0 词表 51 条里**有**),于是每个 chunk 事件被 E3 误报。
|
|
7
|
-
*
|
|
7
|
+
* 判据:**"判据必须来自文件自身的版本,不是判官(运行时)的版本"**。
|
|
8
8
|
*
|
|
9
|
-
* 2026-09-14
|
|
9
|
+
* 2026-09-14 补强(同一判据的完整落地)——一手证据:
|
|
10
10
|
* 1. **0.1.5 的 `foldSurface` 不是"放宽",而是换成了 v3 语义**(对照 `dsh-session@0.1.0-rc.7`
|
|
11
11
|
* 与 `0.1.5-rc.1` 的 `isReplaceOp` / `assertProvenance`):
|
|
12
12
|
* - replace 字段改名:rc.7 `{op,start,end}`(`lib/index.js:300-303`)→ 0.1.5 `{op,startSeq,endSeq}`
|
|
@@ -59,7 +59,7 @@ export const V0_EVENT_TYPES = new Set([
|
|
|
59
59
|
* v2 = retained ∪ {assistant/attempt, assistant/message, delivery-accepted, session/end-seed}
|
|
60
60
|
* = v0 − assistant/chunk + assistant/attempt
|
|
61
61
|
* ⇒ **v2 不含 `assistant/chunk`**(旧实现误用 `V0 ∪ {attempt}`,把 chunk 留在 v2 词表里 =
|
|
62
|
-
* 宽松口径,会漏报 v2 里的顶层 chunk 行 ⇒
|
|
62
|
+
* 宽松口径,会漏报 v2 里的顶层 chunk 行 ⇒ 已修正)。
|
|
63
63
|
*/
|
|
64
64
|
export const V2_EVENT_TYPES = new Set([
|
|
65
65
|
...[...V0_EVENT_TYPES].filter((t) => t !== 'assistant/chunk'),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-log-contract",
|
|
3
3
|
"description": "日志契约守护 — DSH session log contract guard: offline health check (CLI) + pre-write validation for DeepSeek Harness session logs",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.16",
|
|
5
5
|
"packageManager": "pnpm@11.7.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "lib/index.js",
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"README.zh.md"
|
|
30
30
|
],
|
|
31
31
|
"scripts": {
|
|
32
|
-
"check": "node scripts/check-syntax.mjs",
|
|
32
|
+
"check": "node scripts/check-syntax.mjs && node scripts/gen-contracts-doc.mjs --check",
|
|
33
33
|
"test": "vitest run",
|
|
34
|
-
"prepublishOnly": "node scripts/check-pkg-meta.mjs && node scripts/
|
|
34
|
+
"prepublishOnly": "node scripts/check-pkg-meta.mjs && node scripts/prepublish-gate.mjs && pnpm check && pnpm test"
|
|
35
35
|
},
|
|
36
36
|
"keywords": [
|
|
37
37
|
"dsh",
|