diff --git a/docs/workbench-history.md b/docs/workbench-history.md
index 803353d..15b9375 100644
--- a/docs/workbench-history.md
+++ b/docs/workbench-history.md
@@ -153,3 +153,41 @@ Filtering uses the existing bounded metadata index and precedes twelve-row
pagination. Corrupt indexes remain counted separately as unavailable; they are
never treated as passed results. No report is opened, verification rerun, file
pruned or apply capability created by a search.
+
+
+## Compare two saved verification records
+
+History now has independent earlier/later record selectors. Pick two verification
+records from their rows or enter their IDs, then compare. This reads their existing
+metadata and digest-bound bodies; it never calls the verifier, changes the current
+selection/baseline, consumes an approved plan or grants repair permission. Leaving
+the history view, changing an ID or cancelling discards obsolete replies while
+retaining the entered IDs and unrelated drafts. Comparisons use one bounded read
+slot; a second concurrent comparison receives a recoverable busy response.
+
+Both records must describe the same saved directory identity and verification
+mode. Skill reports require matching declared targets and complete, unsaturated
+comparison fields, consistent summaries and finding coverage, unique issue IDs and
+matching recorded exit codes. The same owner computes fresh-baseline and historical
+issue differences, including pairing changed evidence at the same code/location.
+The result distinguishes new, continuing, no-longer-observed and changed evidence;
+no-longer-observed findings are not certified fixes. Old reports do not record
+all tool/policy conditions, so identical selection is not a controlled regression.
+
+Artifact records retain the existing contract, declaration and receipt-bound
+comparison rules. Their finding counts stay separate from per-check changes.
+Different selections/contracts, incomplete or invalid evidence remain unavailable;
+they do not produce invented zero counts. Corrupt or changed stored bodies fail
+before comparison. Current source directories and contract files need not exist:
+this is an offline comparison of saved evidence, not a fresh physical-file check.
+
+POST `/api/history/compare` accepts `beforeId` and `afterId` under the existing
+loopback origin and session guards. History still caps state at 120 files/64 MiB,
+metadata at 16 KiB and each body at 8 MiB. Early row/field caps precede deep schema
+parsing; saturated 256-row/unknown evidence is unavailable. At most 32 code/count
+or reconciliation-index details reach the browser, with full accepted counts and
+explicit truncation. Raw finding messages, source paths, cells, recipes and backup
+paths are not returned. No history or receipt is rewritten.
+
+The workflow borrows [Qodana's baseline distinctions](https://www.jetbrains.com/help/qodana/baseline.html)
+while preserving this workbench's local, non-authenticated evidence boundary.
diff --git a/src/workbench/history.ts b/src/workbench/history.ts
index 19491f4..01c0811 100644
--- a/src/workbench/history.ts
+++ b/src/workbench/history.ts
@@ -4,6 +4,8 @@ import { isAbsolute, join } from "node:path";
import { z } from "zod";
import { parseStrictJson } from "../domain/strict-json.js";
import { readDirectoryEntries, readWorkspaceText } from "../sandbox/workspace-tree.js";
+import {parseComparisonReport,compareVerificationReports} from "./verification-comparison.js";
+import {compareArtifactReports,parseArtifactReport} from "../domain/artifact-report-comparison.js";
export const MAX_OUTPUT = 8 * 1024 * 1024;
const MAX_META = 16 * 1024;
@@ -94,9 +96,40 @@ export class WorkbenchHistory {
async read(id:unknown) {
await this.entries();
const record=await this.metadata(id);
+ return {record,output:await this.output(record)};
+ }
+ private async output(record:HistoryRecord) {
const output=record.dataFile?await readWorkspaceText(join(this.root,record.dataFile),MAX_OUTPUT):undefined;
if(output!==undefined && sha(output)!==record.dataSha256)throw new Error("历史材料摘要不匹配。");
- return {record,output};
+ return output;
+ }
+ async compare(beforeId:unknown,afterId:unknown) {
+ const first=uuid.parse(beforeId),second=uuid.parse(afterId);
+ if(first===second)throw new WorkbenchStateError("请选择两份不同的历史记录。");
+ await this.entries();
+ const before=await this.metadata(first),after=await this.metadata(second);
+ const identity=(record:HistoryRecord)=>({id:record.id,createdAt:record.createdAt,reportSha256:record.dataSha256,exitCode:record.exitCode});
+ const base={schema:"skillsync.history-comparison/v1",before:identity(before),after:identity(after),kind:before.selection.kind,
+ readOnly:true,execution:"not-run",provenance:"not-authenticated"};
+ const unavailable=(reason:string)=>({...base,status:"unavailable" as const,reason});
+ if(before.kind!=="verify"||after.kind!=="verify")return unavailable("not-verification");
+ if(JSON.stringify(before.selection)!==JSON.stringify(after.selection))return unavailable("selection-changed");
+ // Scope predicates precede any body read. Do not reopen current source directories.
+ const left=await this.output(before),right=await this.output(after);
+ try {
+ if(before.selection.kind==="skill"){
+ const a=parseComparisonReport(parseStrictJson(left!)),b=parseComparisonReport(parseStrictJson(right!));
+ if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence");
+ if(JSON.stringify(a.targets.map(row=>[row.name,row.scope,row.profileId]))!==JSON.stringify(b.targets.map(row=>[row.name,row.scope,row.profileId])))return unavailable("declared-targets-changed");
+ return {...base,...compareVerificationReports(a,b)};
+ }
+ const a=parseArtifactReport(parseStrictJson(left!)),b=parseArtifactReport(parseStrictJson(right!));
+ if(a.exitCode!==before.exitCode||b.exitCode!==after.exitCode)return unavailable("incomplete-evidence");
+ const compared=compareArtifactReports(a,b,before.dataSha256!);
+ if(compared.status!=="compared")return unavailable(compared.reason??"incomplete-evidence");
+ const details=(compared.checks??[]).filter(row=>row.change!=="unchanged").map(row=>({code:"reconciliation."+row.index,change:row.change,count:1}));
+ return {...base,status:"compared" as const,counts:compared.findings!,details:details.slice(0,32),detailsTotal:details.length,detailsTruncated:details.length>32};
+ }catch{return unavailable("invalid-evidence");}
}
}
diff --git a/src/workbench/page.ts b/src/workbench/page.ts
index 8041f8b..dec0d25 100644
--- a/src/workbench/page.ts
+++ b/src/workbench/page.ts
@@ -4,13 +4,13 @@ export function workbenchPage(nonce: string): string {
SkillSync 打开 Skill 目录或交付包,查看核验结果、历史记录与修复计划。 预检计划 → 逐批核验 → 保存复核包 按计划只读核验多个交付;修正失败批次后保留已通过的证据。打开复核包只检查完整性,不重新核验原始数据或认证作者。 此操作只读取已有证据,不重新核验原始文件。 选择历史记录作为基线,再核验当前文件。历史记录只读,打开不会重新执行核验或授予修复权限。 选择历史记录作为基线,再核验当前文件。历史记录只读,打开不会重新执行核验或授予修复权限。 在记录旁选择较早、较晚的核验,或输入两份记录编号。不会重新运行核验,也不改变当前修复计划。 仅对照保存的证据;较晚记录未出现某项,不代表当前目录已经修复。报告未完整记录工具与策略条件,不能据此授予执行或修复权限。 选择目录与范围 → 核验 → 查看结果本机核验台
验收多个交付
先核对此次输出
查阅已保存的复核包
查看已存证据
查看已存证据
比较两份已存核验
核验一个目录
尚未核验