dsh-log-contract 0.3.6 → 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.
@@ -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/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';
package/lib/repair.js CHANGED
@@ -317,10 +317,19 @@ export function removeMarkersText(text) {
317
317
  * @param {(event: object) => boolean} dropPredicate - 命中即删除该事件行。
318
318
  * @returns {{ text: string, removed: number, renumbered: number }}
319
319
  */
320
- function renumberWithDrops(parts, dropPredicate) {
320
+ function renumberWithDrops(parts, dropPredicate, dropLinePredicate = null) {
321
+ // 行身份删除(dropLinePredicate):双流交织时保留行与删除行可能共享 seq
322
+ // 值,按 seq 值判删会误删保留行——extractTurn 等场景必须按行身份删除。
323
+ // 两种判据任一命中即删;dropLinePredicate 用行索引(parts 下标)。
321
324
  const dropped = new Set();
322
- for (const raw of parts) {
325
+ const droppedLines = new Set();
326
+ for (let i = 0; i < parts.length; i++) {
327
+ const raw = parts[i];
323
328
  if (!raw.trim()) continue;
329
+ if (dropLinePredicate && dropLinePredicate(i)) {
330
+ droppedLines.add(i);
331
+ continue;
332
+ }
324
333
  let v;
325
334
  try {
326
335
  v = JSON.parse(raw);
@@ -344,11 +353,16 @@ function renumberWithDrops(parts, dropPredicate) {
344
353
  const out = [];
345
354
  let removed = 0;
346
355
  let renumbered = 0;
347
- for (const raw of parts) {
356
+ for (let i = 0; i < parts.length; i++) {
357
+ const raw = parts[i];
348
358
  if (!raw.trim()) {
349
359
  out.push(raw);
350
360
  continue;
351
361
  }
362
+ if (dropLinePredicate && dropLinePredicate(i)) {
363
+ removed++;
364
+ continue;
365
+ }
352
366
  let v;
353
367
  try {
354
368
  v = JSON.parse(raw);
@@ -399,6 +413,101 @@ function renumberWithDrops(parts, dropPredicate) {
399
413
  return { text: fixed, removed, renumbered };
400
414
  }
401
415
 
416
+ /**
417
+ * 按行身份保留 + 全量重编号(extract-turn / keep-ranges 用)。
418
+ *
419
+ * ⚠️ 与 renumberWithDrops 不同:本函数按「行是否保留」决定删除(双流交织时
420
+ * 保留行与删除行可能共享 seq 值,按 seq 值判删会误删保留行——2026-08-31
421
+ * 独立审查实测复现),且保留行**全量重映射** seq 从 0 连续(参考原工具
422
+ * keep-ranges.mjs 的 seqMap 逻辑),同步 seq / seq0 / sourceEventSeqs /
423
+ * surfaceOp 范围。header 行(首行)恒保留且不改。
424
+ * @param {string[]} parts - 按 \n 切分的行数组。
425
+ * @param {(lineIdx: number) => boolean} keepLinePredicate - 行身份保留判定。
426
+ * @returns {{ text: string, removed: number, renumbered: number }}
427
+ */
428
+ function renumberKeptLines(parts, keepLinePredicate) {
429
+ // 第一遍:按物理序收集保留行的事件 seq(chunk 行展开,参考 keep-ranges 原工具)
430
+ const keptLineIdx = [];
431
+ for (let i = 0; i < parts.length; i++) {
432
+ const raw = parts[i];
433
+ if (!raw.trim()) continue;
434
+ if (i === 0) { keptLineIdx.push(i); continue; } // header
435
+ if (keepLinePredicate(i)) keptLineIdx.push(i);
436
+ }
437
+ // 建立 old→new 映射:保留行的展开事件按物理序分配 0,1,2,…
438
+ const seqMap = new Map();
439
+ let counter = 0;
440
+ for (const li of keptLineIdx) {
441
+ if (li === 0) continue; // header 无 seq
442
+ const decoded = decodeLine(parts[li]);
443
+ if (!decoded) continue;
444
+ for (const ev of decoded) {
445
+ if (typeof ev.seq === 'number' && !seqMap.has(ev.seq)) seqMap.set(ev.seq, counter++);
446
+ }
447
+ }
448
+ const remap = (s) => (typeof s === 'number' && seqMap.has(s) ? seqMap.get(s) : null);
449
+ // 第二遍:重写保留行
450
+ const out = [];
451
+ let removed = 0;
452
+ let renumbered = 0;
453
+ const keptSet = new Set(keptLineIdx);
454
+ for (let i = 0; i < parts.length; i++) {
455
+ const raw = parts[i];
456
+ if (!raw.trim()) {
457
+ out.push(raw);
458
+ continue;
459
+ }
460
+ if (!keptSet.has(i)) {
461
+ removed++;
462
+ continue;
463
+ }
464
+ if (i === 0) { out.push(raw); continue; } // header 原样
465
+ let v;
466
+ try {
467
+ v = JSON.parse(raw);
468
+ } catch {
469
+ out.push(raw);
470
+ continue;
471
+ }
472
+ let touched = false;
473
+ if (typeof v.seq === 'number') {
474
+ const ns = remap(v.seq);
475
+ if (ns !== null && ns !== v.seq) {
476
+ v.seq = ns;
477
+ touched = true;
478
+ }
479
+ }
480
+ if (CHUNK_ROW_TYPES.has(v.type) && typeof v.seq0 === 'number') {
481
+ const ns = remap(v.seq0);
482
+ if (ns !== null && ns !== v.seq0) {
483
+ v.seq0 = ns;
484
+ touched = true;
485
+ }
486
+ }
487
+ if (Array.isArray(v.sourceEventSeqs)) {
488
+ const next = v.sourceEventSeqs.map((s) => remap(s) ?? s);
489
+ if (next.some((x, k) => x !== v.sourceEventSeqs[k])) {
490
+ v.sourceEventSeqs = next;
491
+ touched = true;
492
+ }
493
+ }
494
+ if (v.surfaceOp && v.surfaceOp.op === 'replace') {
495
+ const ns = remap(v.surfaceOp.start);
496
+ const ne = remap(v.surfaceOp.end);
497
+ if (ns !== null && ne !== null && (ns !== v.surfaceOp.start || ne !== v.surfaceOp.end)) {
498
+ v.surfaceOp.start = ns;
499
+ v.surfaceOp.end = ne;
500
+ touched = true;
501
+ }
502
+ }
503
+ out.push(touched ? JSON.stringify(v) : raw);
504
+ if (touched) renumbered++;
505
+ }
506
+ let fixed = out.join('\n');
507
+ if (!fixed.endsWith('\n')) fixed += '\n';
508
+ return { text: fixed, removed, renumbered };
509
+ }
510
+
402
511
  /**
403
512
  * 删除"本轮运行失败"的轮次(turn/end 带 reason.kind==='error' 的完整轮次:
404
513
  * [turnStart..turnEnd],含其中的 user 消息与失败产出),并全量重编号。
@@ -430,6 +539,338 @@ export function dropFailedTurnsText(text) {
430
539
  return { ...r, failedTurns: spans.length };
431
540
  }
432
541
 
542
+ /**
543
+ * 尾部 seq 统一平移(fix-tail 收编,源:tools/trim-session.mjs fix-tail 模式)。
544
+ *
545
+ * 修复多写入者/旧光标造成的尾部 seq 回归/间隙:从 startSeq 起(含)的所有
546
+ * 事件的 seq / seq0 / sourceEventSeqs 统一加减 delta。
547
+ * @param {string} text - JSONL 全文。
548
+ * @param {number} startSeq - 平移起点(该 seq 及之后的事件全部平移)。
549
+ * @param {number} delta - 偏移量(减数语义:要加 N 传 −N;要减 N 传 +N)。
550
+ * @returns {{ text: string, changed: number, startSeq: number, delta: number }}
551
+ */
552
+ export function tailRenumberText(text, startSeq, delta) {
553
+ if (!Number.isInteger(delta) || delta === 0) return { text, changed: 0, startSeq, delta };
554
+ const parts = text.split('\n');
555
+ const out = [];
556
+ let changed = 0;
557
+ for (const raw of parts) {
558
+ if (!raw.trim()) {
559
+ out.push(raw);
560
+ continue;
561
+ }
562
+ let v;
563
+ try {
564
+ v = JSON.parse(raw);
565
+ } catch {
566
+ out.push(raw);
567
+ continue;
568
+ }
569
+ let modified = false;
570
+ const seq = v.seq;
571
+ if (typeof seq === 'number' && seq >= startSeq) {
572
+ const ns = seq - delta;
573
+ if (ns < 0) return { text, changed: 0, startSeq, delta, error: `seq ${seq} 平移后为负(delta 过大或起点有误)` };
574
+ v.seq = ns;
575
+ modified = true;
576
+ }
577
+ if (typeof v.seq0 === 'number' && v.seq0 >= startSeq) {
578
+ const ns = v.seq0 - delta;
579
+ if (ns < 0) return { text, changed: 0, startSeq, delta, error: `seq0 ${v.seq0} 平移后为负(delta 过大或起点有误)` };
580
+ v.seq0 = ns;
581
+ modified = true;
582
+ }
583
+ if (Array.isArray(v.sourceEventSeqs)) {
584
+ let mapped;
585
+ try {
586
+ mapped = v.sourceEventSeqs.map((s) => {
587
+ if (s < startSeq) return s;
588
+ const ns = s - delta;
589
+ if (ns < 0) throw new Error(`sourceEventSeqs ${s} 平移后为负`);
590
+ return ns;
591
+ });
592
+ } catch (err) {
593
+ // 与 seq/seq0 的优雅返回一致:错误走 error 字段而非未捕获异常
594
+ return { text, changed: 0, startSeq, delta, error: err.message };
595
+ }
596
+ if (mapped.some((s, i) => s !== v.sourceEventSeqs[i])) {
597
+ v.sourceEventSeqs = mapped;
598
+ modified = true;
599
+ }
600
+ }
601
+ if (modified) changed++;
602
+ out.push(JSON.stringify(v));
603
+ }
604
+ return { text: out.join('\n'), changed, startSeq, delta };
605
+ }
606
+
607
+ /**
608
+ * fork 边界孤儿 spliced 原地归零(--neutralize-orphan)。
609
+ *
610
+ * 2026-08-28 fork 边界事故:裁剪后历史 inbox spliced 残留(已消费消息被
611
+ * 重新排队 → UI 显示"待排队消息")。与 neutralize 同类:原地改
612
+ * `removedCount → 0`(start/inserted 不变,seq/行数不变 → 附着力安全)。
613
+ *
614
+ * ⚠️ 判定(2026-08-31 独立审查修正):不能对"所有 removedCount>0 的
615
+ * next-turn spliced"下手——DSH 每个轮次消费消息都会写这种 spliced
616
+ * (archive 实测 3069 处 removedCount>0,但真 I1 违规只 2 处)。只归零
617
+ * **实际触发 I1 违规**的那条:按官方 inbox 重放(checks.js
618
+ * inboxReplayViolations 同款状态机),`start + removedCount > 队列长`
619
+ * 即孤儿——归零后队列重放不再越界、已消费消息不再重复投递。
620
+ *
621
+ * @param {string} text - JSONL 全文。
622
+ * @returns {{ text: string, neutralized: number, seqs: number[] }}
623
+ */
624
+ export function neutralizeOrphanText(text) {
625
+ const parts = text.split('\n');
626
+ // 解析 header.seedLength(与 checks.js inboxReplayViolations 同口径)
627
+ let seedLength = 0;
628
+ try {
629
+ const hdr = JSON.parse(parts[0] || '');
630
+ if (typeof hdr?.seedLength === 'number' && Number.isSafeInteger(hdr.seedLength)) seedLength = hdr.seedLength;
631
+ } catch { /* header 缺失时从 0 重放 */ }
632
+ const state = { 'next-turn': [], 'next-step': [] };
633
+ const seqs = [];
634
+ let neutralized = 0;
635
+ for (let i = 1; i < parts.length; i++) {
636
+ const raw = parts[i];
637
+ if (!raw.trim()) continue;
638
+ let v;
639
+ try {
640
+ v = JSON.parse(raw);
641
+ } catch {
642
+ continue;
643
+ }
644
+ if (!v || typeof v !== 'object' || v.type !== 'agent/inbox/spliced') continue;
645
+ if (v.seq < seedLength) continue; // seed 区域不重放(与官方一致)
646
+ const splice = v.data;
647
+ if (!splice || typeof splice.target !== 'string' || !['next-turn', 'next-step'].includes(splice.target)) continue;
648
+ const inbox = state[splice.target];
649
+ const removedCount = splice.removedCount ?? 0;
650
+ const start = splice.start ?? 0;
651
+ const inserted = Array.isArray(splice.inserted) ? splice.inserted : [];
652
+ // 只有越界(start+removedCount > 队列长)才判为孤儿——与 I1 规则同判定
653
+ if (start + removedCount > inbox.length && removedCount > 0) {
654
+ v.data.removedCount = 0;
655
+ parts[i] = JSON.stringify(v);
656
+ seqs.push(v.seq);
657
+ neutralized++;
658
+ // 归零后照常推进重放状态(removedCount=0 → 插入 inserted,不删除)
659
+ inbox.splice(start, 0, ...inserted);
660
+ } else {
661
+ // 合法 spliced:正常推进重放状态
662
+ inbox.splice(start, removedCount, ...inserted);
663
+ }
664
+ }
665
+ return { text: parts.join('\n'), neutralized, seqs };
666
+ }
667
+
668
+ /**
669
+ * 双流交织恢复:保留指定轮次(extract-turn 收编,源:tools/recover-interleaved.mjs)。
670
+ *
671
+ * 并发写入把两条事件流交织进同一文件(旧光标重放流 + 真实新轮次)。本原语:
672
+ * 保留 keepTurn 轮次的全部事件 + 其间的无 turn 系统事件(spliced/user/request
673
+ * 等),丢弃其余(重放流、turn-null marker、非目标轮次),全量重编号;
674
+ * 可选把该轮次的第二个 turn/start 起改号为 secondTurnTo(避免未闭合 turn 冲突)。
675
+ * @param {string} text - JSONL 全文(含 header 行)。
676
+ * @param {number} keepTurn - 要保留的轮次号。
677
+ * @param {number|null} secondTurnTo - 第二个同名 turn/start 起改号(默认 null 不改)。
678
+ * @returns {{ text: string, removed: number, renumbered: number, kept: number, secondRenamed: boolean }}
679
+ */
680
+ export function extractTurnText(text, keepTurn, secondTurnTo = null) {
681
+ const parts = text.split('\n');
682
+ const KEEP_NULL_TYPES = new Set(['agent/inbox/spliced', 'user/message', 'request/header', 'session/end-seed', 'command/run', 'command/done']);
683
+ // 第一轮:按行身份决定去留(保留 turn===keepTurn 的行 + 无 turn 系统事件
684
+ // 白名单),并就地改第二个同名轮次的 turn 号。
685
+ // ⚠️ 用行索引而非 seq 值:双流交织时保留行与删除行可能共享 seq(独立审查
686
+ // 实测复现内容静默丢失)——按行身份删除才安全。
687
+ const dropLines = new Set();
688
+ let sawFirstTurnStart = false;
689
+ let inRenumberTurn = false;
690
+ let kept = 0;
691
+ for (let i = 0; i < parts.length; i++) {
692
+ const raw = parts[i];
693
+ if (!raw.trim()) continue;
694
+ let v;
695
+ try {
696
+ v = JSON.parse(raw);
697
+ } catch {
698
+ continue;
699
+ }
700
+ if (i === 0 || v.type === 'session') { kept++; continue; } // header 永远保留
701
+ const d = v.data || {};
702
+ const turn = d.turn;
703
+ const t = v.type;
704
+ let keep = false;
705
+ if (turn === keepTurn) keep = true;
706
+ else if (turn == null && KEEP_NULL_TYPES.has(t)) keep = true;
707
+ if (t === 'turn/start' && d.turn === keepTurn) {
708
+ if (sawFirstTurnStart) inRenumberTurn = true;
709
+ else sawFirstTurnStart = true;
710
+ }
711
+ if (keep) {
712
+ kept++;
713
+ if (inRenumberTurn && turn === keepTurn && secondTurnTo !== null) {
714
+ v.data.turn = secondTurnTo;
715
+ parts[i] = JSON.stringify(v);
716
+ }
717
+ } else {
718
+ dropLines.add(i);
719
+ }
720
+ }
721
+ if (kept === 0) return { text, removed: 0, renumbered: 0, kept: 0, secondRenamed: false };
722
+ // 重编号:按行身份保留 + 全量重映射 seq(从 0 连续),同步所有引用
723
+ const r = renumberKeptLines(parts, (i) => !dropLines.has(i));
724
+ return { ...r, kept, secondRenamed: secondTurnTo !== null && inRenumberTurn };
725
+ }
726
+
727
+ /**
728
+ * 只保留指定 seq 区间,其余删除 + 全量重编号(keep-ranges 收编,
729
+ * 源:tools/keep-ranges.mjs)。
730
+ *
731
+ * 从交织/污染文件中提取干净区段。区间为 1-based 行号(含端点),
732
+ * 如 "10-20,40-50";区段外的行丢弃。header 行(首行)永远保留。
733
+ * @param {string} text - JSONL 全文(含 header 行)。
734
+ * @param {string} rangesSpec - 区间串,如 "10-20,40-50"。
735
+ * @returns {{ text: string, removed: number, renumbered: number, keptLines: number }}
736
+ */
737
+ export function keepRangesText(text, rangesSpec) {
738
+ const ranges = String(rangesSpec).split(',').map((part) => {
739
+ const m = /^(\d+)-(\d+)$/.exec(part.trim());
740
+ if (!m) throw new Error(`区间格式错误: "${part}"(应为 a-b,如 10-20)`);
741
+ const a = Number(m[1]);
742
+ const b = Number(m[2]);
743
+ if (a > b) throw new Error(`区间起点大于终点: "${part}"`);
744
+ return [a, b];
745
+ });
746
+ const parts = text.split('\n');
747
+ // 按行身份删除(双流交织时保留行与删除行可能共享 seq——独立审查发现)
748
+ const dropLines = new Set();
749
+ let keptLines = 0;
750
+ for (let i = 0; i < parts.length; i++) {
751
+ if (i === 0) { keptLines++; continue; } // header 永远保留
752
+ const one = i + 1; // 1-based 行号
753
+ const inRange = ranges.some(([a, b]) => one >= a && one <= b);
754
+ if (inRange) keptLines++;
755
+ else dropLines.add(i);
756
+ }
757
+ if (keptLines === 1) return { text, removed: 0, renumbered: 0, keptLines, error: '区间未命中任何数据行(仅 header 保留)——请检查 1-based 行号' };
758
+ const r = renumberKeptLines(parts, (i) => !dropLines.has(i));
759
+ return { ...r, keptLines };
760
+ }
761
+
762
+ /**
763
+ * 消息文本 token 估算(L5:中文密度校准)。
764
+ *
765
+ * 2026-08 事故教训:chars/4 对中文密集内容低估 ~3.7×(实际 ≈ 字符数×0.94)。
766
+ * 本函数按消息类型取文本并加权:
767
+ * - 中文/全角字符:×0.94(实测密度,不是 /4);
768
+ * - 其余字符(ASCII/空格/标点):×0.25(近似词元密度);
769
+ * - JSON 结构开销(每消息 envelope):+12 tokens 固定。
770
+ * @param {string} text - JSONL 全文(含 header)。
771
+ * @returns {{ tokens: number, cjk: number, other: number }}
772
+ */
773
+ export function estimateTokensText(text) {
774
+ const parts = text.split('\n');
775
+ let cjk = 0;
776
+ let other = 0;
777
+ let messages = 0;
778
+ for (const raw of parts) {
779
+ if (!raw.trim()) continue;
780
+ let v;
781
+ try {
782
+ v = JSON.parse(raw);
783
+ } catch {
784
+ continue;
785
+ }
786
+ let body = '';
787
+ if (v.type === 'user/message') {
788
+ body = (v.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
789
+ messages++;
790
+ } else if (v.type === 'assistant/message' && v.surfaceOp === 'append') {
791
+ body = (v.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
792
+ messages++;
793
+ }
794
+ // 按码点分类:CJK 统一表意文字 + 全角符号按中文字符计
795
+ for (const ch of body) {
796
+ const cp = ch.codePointAt(0);
797
+ if (
798
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK 统一表意
799
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK 扩展 A
800
+ (cp >= 0xff00 && cp <= 0xffef) || // 全角
801
+ (cp >= 0x3000 && cp <= 0x303f) // CJK 标点
802
+ ) cjk++;
803
+ else if (cp > 0x20) other++;
804
+ }
805
+ }
806
+ const tokens = Math.ceil(cjk * 0.94 + other * 0.25 + messages * 12);
807
+ return { tokens, cjk, other, messages };
808
+ }
809
+
810
+ /**
811
+ * 按 token 预算裁剪(L5):自动选保留消息数,使「估算 token ≤ 预算」。
812
+ * 保留 --trim-last N 按消息数语义;--trim-budget 走本函数。
813
+ * 下限保护:至少保留 5 条消息(防空会话)。
814
+ *
815
+ * 精确做法:按消息类型逐条累计 token 估算(复用 estimateTokensText 的
816
+ * 单条消息算法),从最近消息往回累加,直到超过预算——避免比例估算误差。
817
+ * @param {string} text - JSONL 全文。
818
+ * @param {number} budget - token 预算(如 450000)。
819
+ * @returns {{ text: string, removed: number, renumbered: number, kept: number, cutoff: number, estimatedTokens: number }}
820
+ */
821
+ export function trimLastMessagesByBudget(text, budget) {
822
+ const parts = text.split('\n');
823
+ // 逐条消息的 { seq, tokens }:从最近往回累计
824
+ const msgs = [];
825
+ for (const raw of parts) {
826
+ if (!raw.trim()) continue;
827
+ let v;
828
+ try {
829
+ v = JSON.parse(raw);
830
+ } catch {
831
+ continue;
832
+ }
833
+ let body = '';
834
+ if (v.type === 'user/message') {
835
+ body = (v.data?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
836
+ } else if (v.type === 'assistant/message' && v.surfaceOp === 'append') {
837
+ body = (v.data?.message?.content ?? []).filter((b) => b.type === 'text').map((b) => b.text).join(' ');
838
+ } else {
839
+ continue;
840
+ }
841
+ let cjk = 0;
842
+ let other = 0;
843
+ for (const ch of body) {
844
+ const cp = ch.codePointAt(0);
845
+ if (
846
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
847
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
848
+ (cp >= 0xff00 && cp <= 0xffef) ||
849
+ (cp >= 0x3000 && cp <= 0x303f)
850
+ ) cjk++;
851
+ else if (cp > 0x20) other++;
852
+ }
853
+ msgs.push({ seq: v.seq, tokens: Math.ceil(cjk * 0.94 + other * 0.25 + 12) });
854
+ }
855
+ const total = msgs.length;
856
+ const MIN_KEEP = 5;
857
+ // 从最近往回累计,找到「估算 ≤ 预算」的最大保留数
858
+ let acc = 0;
859
+ let kept = 0;
860
+ for (let i = total - 1; i >= 0; i--) {
861
+ if (acc + msgs[i].tokens > budget) break;
862
+ acc += msgs[i].tokens;
863
+ kept++;
864
+ }
865
+ if (kept < MIN_KEEP) kept = MIN_KEEP;
866
+ if (kept >= total) {
867
+ const all = estimateTokensText(text);
868
+ return { text, removed: 0, renumbered: 0, kept: total, cutoff: 0, estimatedTokens: all.tokens };
869
+ }
870
+ const r = trimLastMessagesText(text, kept);
871
+ return { ...r, estimatedTokens: acc };
872
+ }
873
+
433
874
  /**
434
875
  * 裁剪到最近 keepMessages 条 append 消息(保留其所在 turn 的结构),
435
876
  * 同时移除全部 retrace/message-editor marker,并全量重编号。
@@ -739,6 +1180,61 @@ export function repairSession(file, opts = {}) {
739
1180
  const r = trimLastMessagesText(plain, opts.trimLast);
740
1181
  applyFix('trim', r, `裁剪到最近 ${r.kept} 条消息(丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)`);
741
1182
  }
1183
+ if (typeof opts.trimBudget === 'number') {
1184
+ const r = trimLastMessagesByBudget(plain, opts.trimBudget);
1185
+ if (r.removed > 0) {
1186
+ issues.push({ kind: 'trim-budget', detail: `按 token 预算 ${opts.trimBudget} 裁剪:保留 ${r.kept} 条消息(估算 ≈${r.estimatedTokens} tokens,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)` });
1187
+ plain = r.text;
1188
+ } else {
1189
+ issues.push({ kind: 'trim-budget', detail: `估算 ${r.estimatedTokens} tokens ≤ 预算 ${opts.trimBudget}——无需裁剪(${r.kept} 条消息全保留)` });
1190
+ }
1191
+ }
1192
+ // L4 新原语(2026-08-30 任务书 §L4 收编 tools/ 验证工具)
1193
+ if (typeof opts.tailRenumberDelta === 'number') {
1194
+ // 起点自动推导:从首个可平移的 seq 起(即所有事件都平移)。
1195
+ // fix-tail 原工具是 <startLine> <delta> 双参;任务书 §L4 简化为单参 delta
1196
+ // (尾部全部平移)。若需部分平移,传 --tail-renumber 前先 --keep-ranges。
1197
+ const r = tailRenumberText(plain, 0, opts.tailRenumberDelta);
1198
+ if (r.error) {
1199
+ issues.push({ kind: 'tail-renumber', detail: r.error });
1200
+ } else if (r.changed > 0) {
1201
+ issues.push({ kind: 'tail-renumber', detail: `尾部 ${r.changed} 行 seq 平移 delta=${r.delta}` });
1202
+ plain = r.text;
1203
+ }
1204
+ }
1205
+ if (opts.neutralizeOrphan) {
1206
+ const r = neutralizeOrphanText(plain);
1207
+ if (r.neutralized > 0) {
1208
+ issues.push({ kind: 'neutralize-orphan', detail: `原地归零 ${r.neutralized} 个孤儿 inbox spliced(removedCount→0,seq/行数不变)→ 不再重复排队` });
1209
+ plain = r.text;
1210
+ }
1211
+ }
1212
+ if (typeof opts.extractTurn === 'number') {
1213
+ const r = extractTurnText(plain, opts.extractTurn, opts.extractTurnTo ?? null);
1214
+ if (r.kept === 0) {
1215
+ issues.push({ kind: 'extract-turn', detail: `轮次 ${opts.extractTurn} 不存在(无保留行)` });
1216
+ } else {
1217
+ issues.push({ kind: 'extract-turn', detail: `保留轮次 ${opts.extractTurn}(${r.kept} 行,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)${r.secondRenamed ? `,第二同名轮次改号 ${opts.extractTurnTo}` : ''}` });
1218
+ removed += r.removed;
1219
+ renumbered += r.renumbered;
1220
+ plain = r.text;
1221
+ }
1222
+ }
1223
+ if (typeof opts.keepRanges === 'string') {
1224
+ try {
1225
+ const r = keepRangesText(plain, opts.keepRanges);
1226
+ if (r.error) {
1227
+ issues.push({ kind: 'keep-ranges', detail: r.error });
1228
+ } else {
1229
+ issues.push({ kind: 'keep-ranges', detail: `保留区间 ${opts.keepRanges}(${r.keptLines} 行,丢弃 ${r.removed} 行,重编号 ${r.renumbered} 行)` });
1230
+ removed += r.removed;
1231
+ renumbered += r.renumbered;
1232
+ plain = r.text;
1233
+ }
1234
+ } catch (err) {
1235
+ issues.push({ kind: 'keep-ranges', detail: String(err.message ?? err) });
1236
+ }
1237
+ }
742
1238
  if (typeof opts.compactLast === 'number') {
743
1239
  const r = compactLastMessagesText(plain, opts.compactLast);
744
1240
  if (r.compacted) {
@@ -746,6 +1242,13 @@ export function repairSession(file, opts = {}) {
746
1242
  plain = r.text;
747
1243
  }
748
1244
  }
1245
+ // 请求的修复是否"自身失败"(独立于终检):tail-renumber 报 error、
1246
+ // keep-ranges 报 error、extract-turn 无保留行、keep-ranges 未命中——
1247
+ // 任一发生 → ok=false(修复没做成,不能报成功)。
1248
+ const requestedFailed = issues.some((i) =>
1249
+ i.kind === 'tail-renumber' && /负|错误/.test(i.detail) ||
1250
+ i.kind === 'keep-ranges' && i.detail.includes('未命中') ||
1251
+ i.kind === 'extract-turn' && i.detail.includes('不存在'));
749
1252
  // 全部修复完成后做一次终检(中间态的临时违规不阻塞——后续修复可能已消除)
750
1253
  const scanFinal = strictScanText(plain);
751
1254
  const checkFinal = validateSessionLog(loadSessionLogFromText(plain));
@@ -757,7 +1260,7 @@ export function repairSession(file, opts = {}) {
757
1260
  const errs = checkFinal.violations.filter((v) => v.severity === 'error');
758
1261
  issues.push({ kind: 'final-contract', detail: `修复后仍有 ${errs.length} 个 error 级违规(首条:${errs[0]?.id ?? '-'} ${errs[0]?.message?.slice(0, 90) ?? ''})` });
759
1262
  }
760
- const ok = scanFinal.failures.length === 0 && checkFinal.ok;
1263
+ const ok = !requestedFailed && scanFinal.failures.length === 0 && checkFinal.ok;
761
1264
  let backupPath = null;
762
1265
  let applied = false;
763
1266
  if (opts.apply && ok) {
package/lib/validate.js CHANGED
@@ -168,4 +168,73 @@ export function validateSessionLog(log, opts = {}) {
168
168
  };
169
169
  }
170
170
 
171
+ /**
172
+ * resumeVerdict —— `check --resume` 三档结论(L3)。
173
+ *
174
+ * 回答用户「这个会话还能不能用」,三档蕴含(可压缩 ⊂ 可继续 ⊂ 可加载):
175
+ * ✅ 可加载 loadable —— 结构规则全绿(S1-S9/E 系列/W1/W2 等 error 级全清)
176
+ * ✅ 可继续 resumable —— 结构绿 + I1(inbox 重放)绿
177
+ * ✅ 可压缩 compactable —— 前两档绿 + T1/T2(token-meter 配对)绿
178
+ *
179
+ * 纯聚合输出,不新增校验逻辑:直接复用 validateSessionLog 的 violations
180
+ * 按规则 id 分组判定(2026-08-30 事故:离线 check 全绿但实机 token-meter 崩
181
+ * 的教训——T1/T2 必须单独看,不能只看 error 总数)。
182
+ *
183
+ * @param {{ok: boolean, violations: Array, summary: object}} result validateSessionLog 的返回值
184
+ * @returns {{
185
+ * verdict: 'loadable' | 'resumable' | 'compactable' | 'broken',
186
+ * loadable: boolean, resumable: boolean, compactable: boolean,
187
+ * blocking: { loadable: Array, resumable: Array, compactable: Array },
188
+ * }}
189
+ */
190
+ export function resumeVerdict(result) {
191
+ const { ok, violations = [] } = result;
192
+ // 三档各自的「阻断规则集」——按任务书 §L3 档位定义:
193
+ // 可加载:结构层(PERSISTENCE/FRAMING)+ 引擎层非 I1/T1/T2 的 error;
194
+ // 可继续:+ I1(inbox 重放);
195
+ // 可压缩:+ T1/T2(token-meter 配对)。
196
+ const byId = {};
197
+ for (const v of violations) {
198
+ if (v.severity !== 'error') continue;
199
+ (byId[v.id] ??= []).push(v);
200
+ }
201
+ const has = (id) => (byId[id]?.length ?? 0) > 0;
202
+
203
+ // 可加载阻断 = 除 I1/T1/T2 外的所有 error 违规(结构/信封/物理序/工具配对等)。
204
+ // 注意:不能用 result.ok(它把 T1/T2/I1 也计为 error)——三档判定按任务书定义,
205
+ // T1/T2 只影响「可压缩」档、I1 只影响「可继续」档。
206
+ const loadableBlockers = violations.filter(
207
+ (v) => v.severity === 'error' && !['I1', 'T1', 'T2'].includes(v.id),
208
+ ).map((v) => v.id);
209
+ const loadable = loadableBlockers.length === 0;
210
+
211
+ const resumableBlockers = loadable ? (has('I1') ? ['I1'] : []) : [];
212
+ const resumable = loadable && !has('I1');
213
+
214
+ const compactableBlockers = resumable
215
+ ? (has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [])
216
+ : [];
217
+ const compactable = resumable && !has('T1') && !has('T2');
218
+
219
+ // 最差档位
220
+ const verdict = !loadable ? 'broken' : !resumable ? 'loadable' : !compactable ? 'resumable' : 'compactable';
221
+
222
+ return {
223
+ verdict,
224
+ loadable,
225
+ resumable,
226
+ compactable,
227
+ blocking: {
228
+ loadable: [...new Set(loadableBlockers)],
229
+ resumable: resumableBlockers,
230
+ compactable: compactableBlockers,
231
+ },
232
+ violationsByTier: {
233
+ structural: [...new Set(loadableBlockers)],
234
+ inbox: has('I1') ? ['I1'] : [],
235
+ tokenMeter: has('T1') || has('T2') ? ['T1', 'T2'].filter((id) => has(id)) : [],
236
+ },
237
+ };
238
+ }
239
+
171
240
  export { ruleById };
package/package.json CHANGED
@@ -1,7 +1,8 @@
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.6",
4
+ "version": "0.3.7",
5
+ "packageManager": "pnpm@11.7.0",
5
6
  "type": "module",
6
7
  "main": "lib/index.js",
7
8
  "bin": {
@@ -27,6 +28,11 @@
27
28
  "LICENSE",
28
29
  "README.zh.md"
29
30
  ],
31
+ "scripts": {
32
+ "check": "node scripts/check-syntax.mjs",
33
+ "test": "vitest run",
34
+ "prepublishOnly": "pnpm check && pnpm test"
35
+ },
30
36
  "keywords": [
31
37
  "dsh",
32
38
  "deepseek-harness",
@@ -59,9 +65,5 @@
59
65
  "@deepseek-ai/dsh-session": "0.1.0-rc.7",
60
66
  "esbuild": "0.28.2",
61
67
  "vitest": "4.1.11"
62
- },
63
- "scripts": {
64
- "check": "node scripts/check-syntax.mjs",
65
- "test": "vitest run"
66
68
  }
67
- }
69
+ }