dsh-log-contract 0.3.8 → 0.3.10

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/lib/checks.js CHANGED
@@ -361,6 +361,32 @@ export function nullTurnStepViolations(events) {
361
361
  return out
362
362
  }
363
363
 
364
+ /**
365
+ * T5 —— turn/end 缺 reason.kind(2026-09-02 · 1f4d986e malformed turn/end 固化)。
366
+ *
367
+ * 官方 agent-loop 写 turn/end 恒带 `reason: { kind }`(dsh-agent-loop/lib/index.js:620;
368
+ * kind ∈ completed|max-tokens|blocked|aborted|error|interrupted,中断恢复补
369
+ * interrupted,见 dsh-session interruptedTurnClosers)。官方 validation 强制
370
+ * reason.kind 存在——缺失 = malformed → 会话加载失败
371
+ * (SessionPersistenceCorruptionError "malformed pre-react-loop turn/end",1f4d986e)。
372
+ *
373
+ * 判定:turn/end 的 `data.reason?.kind` 缺失/非字符串 = error。
374
+ *
375
+ * @param events - 行序事件流(`{event, lineNo}`)。
376
+ * @returns T5 违规列表。
377
+ */
378
+ export function turnEndReasonViolations(events) {
379
+ const out = []
380
+ for (const { event, lineNo } of events) {
381
+ if (event.type !== 'turn/end') continue
382
+ const reason = event.data?.reason
383
+ if (!reason || typeof reason.kind !== 'string') {
384
+ out.push(violation('T5', { seq: event.seq, lineNo, eventType: event.type }, `turn/end 缺 data.reason.kind(reason=${JSON.stringify(reason)})——官方 validation 拒绝 → 会话加载失败(1f4d986e malformed turn/end;turn/end 必须带 reason.kind,镜像 dsh-agent-loop:620)`))
385
+ }
386
+ }
387
+ return out
388
+ }
389
+
364
390
  /**
365
391
  * P3 —— tool/call ↔ tool/result 配对完整性(考古任务书 B1)。
366
392
  * 每个 tool/call 的 `data.callId` 必须能在 tool/result 的
package/lib/contracts.js CHANGED
@@ -245,6 +245,14 @@ export const CONTRACT_RULES = [
245
245
  source: '复盘 D8 1e99e1ff(2026-09-01):retrace 0.4.17 编辑块 turn:null;修复线 tools/check-null-turn.mjs 判致命',
246
246
  description: '客户端渲染状态机对 turn=null 的 step/start|step/end|assistant/message 无法归属任何 turn → 渲染死循环 → 白屏「载入历史」(1e99e1ff seq 580037-580039)。user/message 天然无 turn 不查;chunk 坐标可缺失不查。step/消息本体必须带真实 turn 号。',
247
247
  },
248
+ {
249
+ id: 'T5',
250
+ title: 'turn/end 必须带 data.reason.kind',
251
+ layer: LAYER.ENGINE,
252
+ severity: SEVERITY.ERROR,
253
+ source: '官方 dsh-agent-loop lib/index.js:620(turn/end = {turn, reason:{kind}});1f4d986e malformed turn/end 事故(2026-09-02,修复线 check-turn-end-reason.mjs)',
254
+ description: '官方 validation 强制 turn/end 的 data.reason.kind 存在(kind ∈ completed|max-tokens|blocked|aborted|error|interrupted)。缺失 = malformed → 官方 SessionPersistenceCorruptionError → 会话加载失败。1f4d986e:retrace 情形③信封 turn/end 漏 reason → 每次编辑后加载失败(已修 0.4.18)。',
255
+ },
248
256
  {
249
257
  id: 'P3',
250
258
  title: 'tool/call ↔ tool/result 配对完整性(考古 B1)',
package/lib/index.js CHANGED
@@ -4,10 +4,10 @@
4
4
  * 日志契约守护(DSH session log contract guard)。
5
5
  * 公开 API:离线体检 + 写前校验 + 修复 + 契约目录。
6
6
  */
7
- export { loadSessionLog, tailSeq } from './log-reader.js';
7
+ export { loadSessionLog, tailSeq, readSessionHeader } from './log-reader.js';
8
8
  export { validateSessionLog, resumeVerdict } from './validate.js';
9
9
  export { createPreWriter, preWriterFromLog } from './prewrite.js';
10
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
- export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
12
+ export { tokenMeterViolations, tokenMeterSourceViolations, stepKeyViolations, nullTurnStepViolations, turnEndReasonViolations, physicalOrderViolations, inboxReplayViolations } from './checks.js';
13
13
  export { auditToolCalls, extractText, extractToolOutputs, indexToolCalls, toolCommandOf } from './archaeology.js';
package/lib/log-reader.js CHANGED
@@ -220,3 +220,52 @@ export function loadSessionLog(path) {
220
220
 
221
221
  return { header, headerLine, rows, events, frameInfo };
222
222
  }
223
+
224
+ /**
225
+ * 轻量读取会话文件 header(只解第一帧,不读全文件帧)。
226
+ *
227
+ * 帧布局:帧 1 = header 行(单行 JSON,~几百字节),帧 2+ = 事件流。
228
+ * 短码推导(工作区 createdAt 序号)需要扫全部会话但只需要 header——
229
+ * 读文件前缀 64KiB 足够覆盖完整帧 1,避免全量读大文件(opena 工作区
230
+ * ~108MiB 压缩)。失败返回 null(调用方降级)。
231
+ *
232
+ * @param {string} path 会话日志文件路径(.jsonl.zstd 或明文 .jsonl)。
233
+ * @returns {object|null} header 对象;无法解析返回 null。
234
+ */
235
+ export function readSessionHeader(path) {
236
+ let head;
237
+ try {
238
+ const fd = fs.openSync(path, 'r');
239
+ const buf = Buffer.alloc(64 * 1024);
240
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
241
+ fs.closeSync(fd);
242
+ head = buf.subarray(0, n);
243
+ } catch {
244
+ return null;
245
+ }
246
+ if (head.length < 4 || head.readUInt32LE(0) !== ZSTD_MAGIC) {
247
+ // 明文(无 zstd magic):直接取首行
248
+ try {
249
+ const line = head.toString('utf8').split('\n').find((l) => l.trim().length > 0);
250
+ return line ? JSON.parse(line) : null;
251
+ } catch {
252
+ return null;
253
+ }
254
+ }
255
+ const { frames } = scanZstdFrames(head);
256
+ if (frames.length === 0) return null;
257
+ const [s, e] = frames[0];
258
+ let plain;
259
+ try {
260
+ plain = zstdDecompressSync(head.subarray(s, e));
261
+ } catch {
262
+ return null;
263
+ }
264
+ const line = plain.toString('utf8').split('\n').find((l) => l.trim().length > 0);
265
+ if (!line) return null;
266
+ try {
267
+ return JSON.parse(line);
268
+ } catch {
269
+ return null;
270
+ }
271
+ }
package/lib/prewrite.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * 所有判定复用 `lib/checks.js`(与离线体检同一套逻辑),
18
18
  * 保证"体检看到的问题 = 写入前拦下的问题"。
19
19
  */
20
- import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, violation } from './checks.js';
20
+ import { envelopeViolations, engineViolations, finalFold, isSafeInt, nullTurnStepViolations, pluginViolations, replaySurface, stepKeyViolations, tokenMeterViolations, turnEndReasonViolations, violation } from './checks.js';
21
21
 
22
22
  /** retrace 类 marker:data.editor 存在(assistant/message replace,turn/step=null)。 */
23
23
  function isKnownMarkerCandidate(event) {
@@ -111,6 +111,11 @@ export function createPreWriter(input = {}) {
111
111
  if (v.seq !== lastCandidate?.seq) continue;
112
112
  violations.push(v);
113
113
  }
114
+ // T5 —— 拟写 turn/end 缺 reason.kind → error 拒绝(1f4d986e 防再犯)
115
+ for (const v of turnEndReasonViolations(candidateEvents.map((event) => ({ event })))) {
116
+ if (v.seq !== lastCandidate?.seq) continue;
117
+ violations.push(v);
118
+ }
114
119
  const bySeverity = { error: 0, warning: 0, info: 0 };
115
120
  for (const v of violations) bySeverity[v.severity] = (bySeverity[v.severity] ?? 0) + 1;
116
121
  return {
package/lib/validate.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  pluginViolations,
21
21
  replaySurface,
22
22
  stepKeyViolations,
23
+ turnEndReasonViolations,
23
24
  violation,
24
25
  tokenMeterViolations,
25
26
  tokenMeterSourceViolations,
@@ -117,10 +118,11 @@ export function validateSessionLog(log, opts = {}) {
117
118
  violations.push(...tokenMeterViolations(events));
118
119
  violations.push(...tokenMeterSourceViolations(events));
119
120
 
120
- // ── T3/T4 · 渲染层(复盘 2026-09-02 1e99e1ff 白屏):节点 key 唯一 + turn 缺失 ──
121
- // 数据层"健康"≠ 客户端能渲染:check/官方加载/分页全绿仍可能白屏(节点 key 冲突)。
121
+ // ── T3/T4/T5 · 渲染/契约层(复盘 2026-09-02):节点 key 唯一 + turn 缺失 + turn/end reason ──
122
+ // 数据层"健康"≠ 客户端能渲染/能加载:check/官方加载/分页全绿仍可能白屏或加载失败。
122
123
  violations.push(...stepKeyViolations(events));
123
124
  violations.push(...nullTurnStepViolations(events));
125
+ violations.push(...turnEndReasonViolations(events));
124
126
 
125
127
  // ── I1 · inbox seed 相对重放(fork 边界孤儿;交接书 L1)──────────────
126
128
  violations.push(...inboxReplayViolations(events, header));
@@ -211,7 +213,7 @@ export function resumeVerdict(result) {
211
213
  // 注意:不能用 result.ok(它把 T1/T2/T3/T4/I1 也计为 error)——三档判定按任务书定义,
212
214
  // T1/T2/T3/T4 只影响「可压缩」档、I1 只影响「可继续」档。
213
215
  const loadableBlockers = violations.filter(
214
- (v) => v.severity === 'error' && !['I1', 'T1', 'T2', 'T3', 'T4'].includes(v.id),
216
+ (v) => v.severity === 'error' && !['I1', 'T1', 'T2', 'T3', 'T4', 'T5'].includes(v.id),
215
217
  ).map((v) => v.id);
216
218
  const loadable = loadableBlockers.length === 0;
217
219
 
@@ -219,9 +221,9 @@ export function resumeVerdict(result) {
219
221
  const resumable = loadable && !has('I1');
220
222
 
221
223
  const compactableBlockers = resumable
222
- ? (has('T1') || has('T2') || has('T3') || has('T4') ? ['T1', 'T2', 'T3', 'T4'].filter((id) => has(id)) : [])
224
+ ? (has('T1') || has('T2') || has('T3') || has('T4') || has('T5') ? ['T1', 'T2', 'T3', 'T4', 'T5'].filter((id) => has(id)) : [])
223
225
  : [];
224
- const compactable = resumable && !has('T1') && !has('T2') && !has('T3') && !has('T4');
226
+ const compactable = resumable && !has('T1') && !has('T2') && !has('T3') && !has('T4') && !has('T5');
225
227
 
226
228
  // 最差档位
227
229
  const verdict = !loadable ? 'broken' : !resumable ? 'loadable' : !compactable ? 'resumable' : 'compactable';
@@ -239,7 +241,7 @@ export function resumeVerdict(result) {
239
241
  violationsByTier: {
240
242
  structural: [...new Set(loadableBlockers)],
241
243
  inbox: has('I1') ? ['I1'] : [],
242
- tokenMeter: has('T1') || has('T2') || has('T3') || has('T4') ? ['T1', 'T2', 'T3', 'T4'].filter((id) => has(id)) : [],
244
+ tokenMeter: has('T1') || has('T2') || has('T3') || has('T4') || has('T5') ? ['T1', 'T2', 'T3', 'T4', 'T5'].filter((id) => has(id)) : [],
243
245
  },
244
246
  };
245
247
  }
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.8",
4
+ "version": "0.3.10",
5
5
  "packageManager": "pnpm@11.7.0",
6
6
  "type": "module",
7
7
  "main": "lib/index.js",