dsh-vscode-mode 0.19.2 → 0.20.1

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/index.js CHANGED
@@ -1209,11 +1209,14 @@ function fileMaxBatch(records, path) {
1209
1209
  for (const r of records.values()) if (r.path === path && Number.isInteger(r.batch) && r.batch > m) m = r.batch;
1210
1210
  return m;
1211
1211
  }
1212
- /** 记录桶裁剪:超出 MAX_RECORDS 时剔除最旧的。 */
1212
+ /** @public @author ddj 2026年10月26号 @param map Legacy records. @param max Cache target, never a pending retention limit. */
1213
1213
  function prune(map, max = 200) {
1214
1214
  if (map.size <= max) return;
1215
- const sorted = [...map.values()].sort((a, b) => a.at < b.at ? -1 : 1);
1216
- for (let i = 0; i < sorted.length - max; i++) map.delete(sorted[i].callId);
1215
+ const sorted = [...map.values()].filter((record) => record.archived && recordResolved(record)).sort((a, b) => a.at < b.at ? -1 : 1);
1216
+ for (const record of sorted) {
1217
+ if (map.size <= max) break;
1218
+ map.delete(record.callId);
1219
+ }
1217
1220
  }
1218
1221
  /**
1219
1222
  * 归档条目构造(一条批次一条)。
@@ -1926,7 +1929,7 @@ function dbFor(cwd) {
1926
1929
  db.exec("CREATE TABLE IF NOT EXISTS metadata (name TEXT PRIMARY KEY, value TEXT NOT NULL)");
1927
1930
  const version = db.prepare("SELECT value FROM metadata WHERE name='schema'").get();
1928
1931
  const workspace = db.prepare("SELECT value FROM metadata WHERE name='cwd'").get();
1929
- if (version && version.value !== "2" && version.value !== "3" || workspace && workspace.value !== cwd) throw new Error("归档数据库 schema/工作区不匹配");
1932
+ if (version && version.value !== "2" && version.value !== "3" && version.value !== "4" || workspace && workspace.value !== cwd) throw new Error("归档数据库 schema/工作区不匹配");
1930
1933
  if (!!version !== !!workspace) throw new Error("归档数据库元数据不完整");
1931
1934
  if (!version) {
1932
1935
  if (db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name<>'metadata' LIMIT 1").get()) throw new Error("归档数据库缺失元数据且已有数据表");
@@ -1962,7 +1965,7 @@ function dbFor(cwd) {
1962
1965
  throw error;
1963
1966
  }
1964
1967
  }
1965
- if (version?.value === "3") {
1968
+ if (version && (version.value === "3" || version.value === "4")) {
1966
1969
  const active = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='active_records'").get();
1967
1970
  const source = db.prepare("SELECT value FROM metadata WHERE name='active_source'").get();
1968
1971
  if (!active || !source) throw new Error("活跃记录数据库元数据不完整");
@@ -1984,7 +1987,8 @@ function dbFor(cwd) {
1984
1987
  */
1985
1988
  async function migrateActive(cwd, source, confirm) {
1986
1989
  const db = dbFor(cwd);
1987
- if (String(db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value) === "3") return readActive(cwd);
1990
+ const version = String(db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value);
1991
+ if (version === "3" || version === "4") return readActive(cwd);
1988
1992
  const digest = source === null ? "absent" : createHash("sha256").update(source).digest("hex");
1989
1993
  let recs = {};
1990
1994
  if (source !== null) {
@@ -2026,7 +2030,7 @@ async function migrateActive(cwd, source, confirm) {
2026
2030
  * @returns 迁移完成返回 true
2027
2031
  */
2028
2032
  function activeReady(cwd) {
2029
- return dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get().value === "3";
2033
+ return ["3", "4"].includes(String(dbFor(cwd).prepare("SELECT value FROM metadata WHERE name='schema'").get().value));
2030
2034
  }
2031
2035
  /**
2032
2036
  * 对旧文件原文核对迁移标记,旧 Host 若继续写旧 sidecar 则拒绝启用新活跃库。
@@ -2048,7 +2052,7 @@ function checkActiveSource(cwd, source) {
2048
2052
  */
2049
2053
  function readActive(cwd) {
2050
2054
  const db = dbFor(cwd);
2051
- if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
2055
+ if (!activeReady(cwd)) throw new Error("工作区活跃记录未迁移");
2052
2056
  const recs = db.prepare("SELECT payload FROM active_records ORDER BY rowid").all().map((row) => JSON.parse(String(row.payload)));
2053
2057
  return new Map(recs.map((rec) => [rec.callId, rec]));
2054
2058
  }
@@ -2062,7 +2066,7 @@ function readActive(cwd) {
2062
2066
  */
2063
2067
  function commitActive(cwd, changes, entries = []) {
2064
2068
  const db = dbFor(cwd);
2065
- if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
2069
+ if (!activeReady(cwd)) throw new Error("工作区活跃记录未迁移");
2066
2070
  const upsert = db.prepare(`INSERT INTO active_records(call_id,payload,path,archived,pending,at) VALUES(?,?,?,?,?,?)
2067
2071
  ON CONFLICT(call_id) DO UPDATE SET payload=excluded.payload,path=excluded.path,
2068
2072
  archived=excluded.archived,pending=excluded.pending,at=excluded.at`);
@@ -2090,7 +2094,7 @@ function commitActive(cwd, changes, entries = []) {
2090
2094
  */
2091
2095
  function activeSummary(cwd) {
2092
2096
  const db = dbFor(cwd);
2093
- if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value !== "3") throw new Error("工作区活跃记录未迁移");
2097
+ if (!activeReady(cwd)) throw new Error("工作区活跃记录未迁移");
2094
2098
  return {
2095
2099
  active: Number(db.prepare("SELECT count(*) AS n FROM active_records WHERE archived=0").get().n),
2096
2100
  pendingByFile: db.prepare("SELECT path, sum(pending) AS pending FROM active_records WHERE archived=0 AND pending>0 GROUP BY path ORDER BY pending DESC").all().map((row) => ({
@@ -2534,10 +2538,16 @@ async function readForCache(ctx, session, cache, path) {
2534
2538
  */
2535
2539
  async function recordIsStale(ctx, session, cache, rec) {
2536
2540
  if (rec.superseded === true) return true;
2537
- if (!Array.isArray(rec.hunks) || !rec.hunks.length) return true;
2541
+ if (!Array.isArray(rec.hunks) || !rec.hunks.length) {
2542
+ rec.conflict = true;
2543
+ return false;
2544
+ }
2538
2545
  const state = await readForCache(ctx, session, cache, rec.path);
2539
2546
  if (state.kind === "unavailable") return false;
2540
- if (state.kind === "missing") return true;
2547
+ if (state.kind === "missing") {
2548
+ rec.conflict = true;
2549
+ return false;
2550
+ }
2541
2551
  if (rec.create === true) return false;
2542
2552
  const pending = rec.hunks.map((_, idx) => ({
2543
2553
  idx,
@@ -6968,7 +6978,7 @@ function buildLocalRecord(exec, args, result) {
6968
6978
  if (typeof path !== "string" || !path) return null;
6969
6979
  const before = value && typeof value.before === "string" ? value.before : null;
6970
6980
  const after = value && typeof value.after === "string" ? value.after : typeof args.content === "string" ? args.content : null;
6971
- const create = exec.name === "write" && !!value && value.before === null;
6981
+ const create = exec.name === "write" && value?.operation === "create";
6972
6982
  const callHunk = exec.name === "edit" && typeof args.old_string === "string" && typeof args.new_string === "string" ? {
6973
6983
  oldText: args.old_string,
6974
6984
  newText: args.new_string
@@ -7170,6 +7180,756 @@ async function captureToolResult(ctx, registry, exec, result) {
7170
7180
  }
7171
7181
  }
7172
7182
  //#endregion
7183
+ //#region src/shared/reviewRegions.ts
7184
+ /** @public @author ddj 2026年10月27号 @param diff Immutable official patch. @returns Contiguous change runs with stable source-coordinate identities. */
7185
+ function reviewBlocks(diff) {
7186
+ if (diff.kind !== "text") return [];
7187
+ const blocks = [];
7188
+ for (const [hunkIndex, hunk] of diff.hunks.entries()) {
7189
+ let oldLine = hunk.oldStart, newLine = hunk.newStart;
7190
+ let before = [];
7191
+ let current;
7192
+ for (const line of hunk.lines) {
7193
+ const marker = line[0], text = line.slice(1);
7194
+ if (marker === " ") {
7195
+ if (current) {
7196
+ current.contextAfter.push(text);
7197
+ current = void 0;
7198
+ } else if (blocks.at(-1)?.hunkIndex === hunkIndex) blocks.at(-1).contextAfter.push(text);
7199
+ before.push(text);
7200
+ oldLine++;
7201
+ newLine++;
7202
+ continue;
7203
+ }
7204
+ if (marker !== "+" && marker !== "-") continue;
7205
+ if (!current) {
7206
+ current = {
7207
+ id: `${hunkIndex}:${oldLine}:${newLine}`,
7208
+ hunkIndex,
7209
+ oldStart: oldLine,
7210
+ newStart: newLine,
7211
+ oldLines: [],
7212
+ newLines: [],
7213
+ contextBefore: before.slice(-3),
7214
+ contextAfter: []
7215
+ };
7216
+ blocks.push(current);
7217
+ before = [];
7218
+ }
7219
+ if (marker === "-") {
7220
+ current.oldLines.push(text);
7221
+ oldLine++;
7222
+ } else {
7223
+ current.newLines.push(text);
7224
+ newLine++;
7225
+ }
7226
+ }
7227
+ }
7228
+ return blocks;
7229
+ }
7230
+ //#endregion
7231
+ //#region src/reviewDb.ts
7232
+ /** Official history shares the archive connection, but never the legacy callId namespace. @author ddj 2026年10月26号 */
7233
+ /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @returns Schema4 connection; DDL and version change roll back together. */
7234
+ function reviewDb(cwd) {
7235
+ if (!activeReady(cwd)) throw new Error("官方审查要求先完成活跃库迁移");
7236
+ const db = dbFor(cwd);
7237
+ if (db.prepare("SELECT value FROM metadata WHERE name='schema'").get().value === "4") {
7238
+ migrateBlocks(cwd, db);
7239
+ return db;
7240
+ }
7241
+ db.exec("BEGIN IMMEDIATE");
7242
+ try {
7243
+ db.exec(`CREATE TABLE review_snapshots (
7244
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
7245
+ payload TEXT NOT NULL, digest TEXT NOT NULL, current INTEGER NOT NULL DEFAULT 1,
7246
+ PRIMARY KEY(session_id,turn,seq));
7247
+ CREATE TABLE review_files (
7248
+ id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
7249
+ identity TEXT NOT NULL, summary TEXT NOT NULL, diff TEXT NOT NULL,
7250
+ decision TEXT NOT NULL DEFAULT 'pending', revision INTEGER NOT NULL DEFAULT 0,
7251
+ current INTEGER NOT NULL DEFAULT 1);
7252
+ CREATE INDEX review_pending ON review_files(current,decision,id);
7253
+ CREATE INDEX review_turn ON review_files(session_id,turn,seq);
7254
+ CREATE TABLE review_capture (
7255
+ session_id TEXT NOT NULL, turn INTEGER NOT NULL, seq INTEGER NOT NULL,
7256
+ state TEXT NOT NULL, error TEXT, PRIMARY KEY(session_id,turn,seq));
7257
+ CREATE TABLE review_ops (
7258
+ operation_id TEXT PRIMARY KEY, request TEXT NOT NULL, state TEXT NOT NULL, result TEXT,
7259
+ evidence TEXT);
7260
+ UPDATE metadata SET value='4' WHERE name='schema';`);
7261
+ db.exec("COMMIT");
7262
+ } catch (error) {
7263
+ if (db.isTransaction) db.exec("ROLLBACK");
7264
+ throw error;
7265
+ }
7266
+ migrateBlocks(cwd, db);
7267
+ return db;
7268
+ }
7269
+ /** @private @author ddj 2026年10月27号 @param cwd Workspace backup identity. @param db Open schema4 database. Extends the official-review sub-schema without changing legacy archive schema. @throws Migration failure, leaving prior decisions intact. */
7270
+ function migrateBlocks(cwd, db) {
7271
+ if (db.prepare("SELECT value FROM metadata WHERE name='review_schema'").get()) return;
7272
+ const backup = archiveDbFile(cwd) + ".before-review-blocks.bak";
7273
+ if (!existsSync(backup)) db.exec(`VACUUM INTO '${backup.replace(/'/g, "''")}'`);
7274
+ db.exec("BEGIN IMMEDIATE");
7275
+ try {
7276
+ db.exec(`CREATE TABLE review_blocks (file_id TEXT NOT NULL, block_id TEXT NOT NULL,
7277
+ decision TEXT NOT NULL, PRIMARY KEY(file_id,block_id));
7278
+ CREATE TABLE review_evidence (file_id TEXT PRIMARY KEY, payload TEXT NOT NULL);
7279
+ INSERT INTO metadata(name,value) VALUES('review_schema','2');`);
7280
+ const files = db.prepare("SELECT id,diff,decision FROM review_files").all();
7281
+ for (const file of files) seedBlocks(db, String(file.id), JSON.parse(String(file.diff)), String(file.decision));
7282
+ db.exec("COMMIT");
7283
+ } catch (error) {
7284
+ if (db.isTransaction) db.exec("ROLLBACK");
7285
+ throw error;
7286
+ }
7287
+ }
7288
+ /** @private @author ddj 2026年10月27号 @param db Transaction connection. @param id File record. @param diff Immutable official source. @param decision Prior file decision. Seeds deterministic blocks without changing source facts. */
7289
+ function seedBlocks(db, id, diff, decision) {
7290
+ const insert = db.prepare("INSERT INTO review_blocks VALUES(?,?,?)");
7291
+ for (const block of reviewBlocks(diff)) insert.run(id, block.id, decision);
7292
+ }
7293
+ /** @private @author ddj 2026年10月26号 @param text Canonical serialized input. @returns Collision-resistant identity. */
7294
+ function digest(text) {
7295
+ return createHash("sha256").update(text).digest("hex");
7296
+ }
7297
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param snapshot Complete staged event. @returns False for late versions; commits facts, pointers and capture state atomically. */
7298
+ function saveSnapshot(cwd, snapshot) {
7299
+ const db = reviewDb(cwd);
7300
+ const { sessionId, turn, seq } = snapshot;
7301
+ if (snapshot.summary.cwd !== cwd || snapshot.summary.turn !== turn || snapshot.files.length !== snapshot.summary.files.length) throw new Error("官方采集不完整");
7302
+ if (new Set(snapshot.files.map((file) => file.identity)).size !== snapshot.files.length) throw new Error("官方文件身份重复");
7303
+ const hash = digest(JSON.stringify({
7304
+ ...snapshot,
7305
+ capturedAt: ""
7306
+ }));
7307
+ db.exec("BEGIN IMMEDIATE");
7308
+ try {
7309
+ const prior = db.prepare("SELECT digest FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?").get(sessionId, turn, seq);
7310
+ if (prior && prior.digest !== hash) throw new Error("同一官方事件的内容发生变化");
7311
+ const latest = db.prepare("SELECT MAX(seq) AS seq FROM review_snapshots WHERE session_id=? AND turn=?").get(sessionId, turn);
7312
+ if (prior || typeof latest.seq === "number" && latest.seq > seq) {
7313
+ db.exec("COMMIT");
7314
+ return false;
7315
+ }
7316
+ insertSnapshot(db, snapshot, hash);
7317
+ db.prepare("INSERT INTO review_capture VALUES(?,?,?,'complete',NULL) ON CONFLICT(session_id,turn,seq) DO UPDATE SET state='complete',error=NULL").run(sessionId, turn, seq);
7318
+ db.exec("COMMIT");
7319
+ return true;
7320
+ } catch (error) {
7321
+ if (db.isTransaction) db.exec("ROLLBACK");
7322
+ throw error;
7323
+ }
7324
+ }
7325
+ /** @public @author ddj 2026年10月27号 @param cwd Workspace. @param resolvePath Backend path adapter. @returns Repairs only historical object-string identities; preserves IDs, decisions and immutable official facts. @throws Resolution or concurrent payload changes. */
7326
+ async function repairPaths(cwd, resolvePath) {
7327
+ const db = reviewDb(cwd);
7328
+ const broken = db.prepare(`SELECT DISTINCT s.* FROM review_snapshots s JOIN review_files f
7329
+ USING(session_id,turn,seq) WHERE f.identity='[object Object]'`).all();
7330
+ for (const row of broken) {
7331
+ const snapshot = JSON.parse(String(row.payload));
7332
+ for (const file of snapshot.files) {
7333
+ if (file.identity !== "[object Object]") continue;
7334
+ file.identity = await resolvePath(snapshot.summary.cwd, file.diff.path);
7335
+ if (!file.identity || file.identity === "[object Object]") throw new Error("官方文件路径修复失败");
7336
+ }
7337
+ if (new Set(snapshot.files.map((file) => file.identity)).size !== snapshot.files.length) throw new Error("官方文件身份重复");
7338
+ db.exec("BEGIN IMMEDIATE");
7339
+ try {
7340
+ if (db.prepare("SELECT payload FROM review_snapshots WHERE session_id=? AND turn=? AND seq=?").get(snapshot.sessionId, snapshot.turn, snapshot.seq)?.payload === row.payload) {
7341
+ for (const file of snapshot.files) db.prepare(`UPDATE review_files SET identity=? WHERE session_id=? AND turn=? AND seq=?
7342
+ AND identity='[object Object]' AND json_extract(diff,'$.path')=?`).run(file.identity, snapshot.sessionId, snapshot.turn, snapshot.seq, file.diff.path);
7343
+ db.prepare("UPDATE review_snapshots SET payload=?,digest=? WHERE session_id=? AND turn=? AND seq=?").run(JSON.stringify(snapshot), digest(JSON.stringify({
7344
+ ...snapshot,
7345
+ capturedAt: ""
7346
+ })), snapshot.sessionId, snapshot.turn, snapshot.seq);
7347
+ }
7348
+ db.exec("COMMIT");
7349
+ } catch (error) {
7350
+ if (db.isTransaction) db.exec("ROLLBACK");
7351
+ throw error;
7352
+ }
7353
+ }
7354
+ }
7355
+ /** @private @author ddj 2026年10月26号 @param db Transaction. @param snapshot Source payload. @param hash Source digest. */
7356
+ function insertSnapshot(db, snapshot, hash) {
7357
+ const { sessionId, turn, seq } = snapshot;
7358
+ const previous = db.prepare("SELECT id,identity,diff,decision FROM review_files WHERE session_id=? AND turn=? AND current=1").all(sessionId, turn);
7359
+ db.prepare("UPDATE review_snapshots SET current=0 WHERE session_id=? AND turn=?").run(sessionId, turn);
7360
+ db.prepare("UPDATE review_files SET current=0 WHERE session_id=? AND turn=?").run(sessionId, turn);
7361
+ db.prepare("INSERT INTO review_snapshots VALUES(?,?,?,?,?,1)").run(sessionId, turn, seq, JSON.stringify(snapshot), hash);
7362
+ const insert = db.prepare("INSERT INTO review_files VALUES(?,?,?,?,?,?,?,?,0,1)");
7363
+ for (const [index, file] of snapshot.files.entries()) {
7364
+ const encoded = JSON.stringify(file.diff);
7365
+ const unchanged = previous.find((item) => item.identity === file.identity && item.diff === encoded);
7366
+ const id = digest(JSON.stringify([
7367
+ sessionId,
7368
+ turn,
7369
+ seq,
7370
+ file.identity
7371
+ ]));
7372
+ const decision = String(unchanged?.decision ?? "pending");
7373
+ insert.run(id, sessionId, turn, seq, file.identity, JSON.stringify(snapshot.summary.files[index]), encoded, decision);
7374
+ seedBlocks(db, id, file.diff, decision);
7375
+ if (unchanged) {
7376
+ db.prepare(`UPDATE review_blocks SET decision=(SELECT p.decision FROM review_blocks p
7377
+ WHERE p.file_id=? AND p.block_id=review_blocks.block_id)
7378
+ WHERE file_id=? AND EXISTS(SELECT 1 FROM review_blocks p WHERE p.file_id=? AND p.block_id=review_blocks.block_id)`).run(unchanged.id, id, unchanged.id);
7379
+ db.prepare("INSERT INTO review_evidence SELECT ?,payload FROM review_evidence WHERE file_id=?").run(id, unchanged.id);
7380
+ }
7381
+ }
7382
+ }
7383
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param gap Announced event identity. @param state Durable inbox or explicit failure. */
7384
+ function captureState(cwd, gap, state) {
7385
+ reviewDb(cwd).prepare(`INSERT INTO review_capture VALUES(?,?,?,?,?) ON CONFLICT(session_id,turn,seq)
7386
+ DO UPDATE SET state=excluded.state,error=excluded.error WHERE review_capture.state<>'complete'`).run(gap.sessionId, gap.turn, gap.seq, state, gap.error);
7387
+ }
7388
+ /** @private @author ddj 2026年10月26号 @param row File and state. @returns Wire-safe summary without hunks. */
7389
+ function itemOf(row) {
7390
+ const summary = JSON.parse(String(row.summary));
7391
+ const kind = row.kind ?? JSON.parse(String(row.diff)).kind;
7392
+ return {
7393
+ id: String(row.id),
7394
+ sessionId: String(row.session_id),
7395
+ turn: Number(row.turn),
7396
+ seq: Number(row.seq),
7397
+ ...summary,
7398
+ currentPath: String(row.identity),
7399
+ kind,
7400
+ decision: row.decision,
7401
+ revision: Number(row.revision),
7402
+ current: row.current === 1
7403
+ };
7404
+ }
7405
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param cursor Page offset. @param history Include withdrawn/decided history. @returns Bounded metadata page; pending across all source sessions/turns. */
7406
+ function reviewPage(cwd, cursor = 0, history = false, path) {
7407
+ const db = reviewDb(cwd);
7408
+ const offset = Number.isSafeInteger(cursor) && cursor >= 0 ? cursor : 0;
7409
+ const conditions = history ? [] : ["current=1 AND decision='pending'"];
7410
+ if (path !== void 0) conditions.push("identity=?");
7411
+ const clause = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
7412
+ const parameters = path === void 0 ? [offset] : [path, offset];
7413
+ const rows = db.prepare(`SELECT id,session_id,turn,seq,identity,summary,decision,revision,current,
7414
+ json_extract(diff,'$.kind') AS kind FROM review_files ${clause} ORDER BY rowid DESC LIMIT 51 OFFSET ?`).all(...parameters);
7415
+ const count = db.prepare("SELECT COUNT(*) AS n FROM review_files WHERE current=1 AND decision='pending'").get();
7416
+ const gaps = db.prepare("SELECT * FROM review_capture WHERE state<>'complete' ORDER BY seq DESC LIMIT 50").all();
7417
+ const capped = db.prepare(`SELECT session_id,turn,seq,json_extract(payload,'$.summary.total') AS total,
7418
+ json_array_length(payload,'$.summary.files') AS listed FROM review_snapshots WHERE current=1
7419
+ AND json_extract(payload,'$.summary.total')>json_array_length(payload,'$.summary.files') LIMIT 50`).all();
7420
+ const files = db.prepare(`SELECT identity,MIN(json_extract(summary,'$.display')) AS label,COUNT(*) AS n
7421
+ FROM review_files WHERE current=1 AND decision='pending' GROUP BY identity ORDER BY identity`).all();
7422
+ const covered = db.prepare("SELECT DISTINCT identity FROM review_files").all();
7423
+ return {
7424
+ overview: {
7425
+ files: files.map((row) => ({
7426
+ sourceKind: "official",
7427
+ path: String(row.identity),
7428
+ label: String(row.label),
7429
+ pending: Number(row.n)
7430
+ })),
7431
+ coveredPaths: covered.map((row) => String(row.identity))
7432
+ },
7433
+ items: rows.slice(0, 50).map(itemOf),
7434
+ pending: Number(count.n),
7435
+ nextCursor: rows.length > 50 ? offset + 50 : null,
7436
+ gaps: [...gaps.map((row) => ({
7437
+ sessionId: String(row.session_id),
7438
+ turn: Number(row.turn),
7439
+ seq: Number(row.seq),
7440
+ error: String(row.error || "采集中或上次采集被中断")
7441
+ })), ...capped.map((row) => ({
7442
+ sessionId: String(row.session_id),
7443
+ turn: Number(row.turn),
7444
+ seq: Number(row.seq),
7445
+ error: `官方列表已截断:总计 ${row.total} 个文件,仅提供 ${row.listed} 个`
7446
+ }))]
7447
+ };
7448
+ }
7449
+ /** @public @author ddj 2026年10月26号 @param cwd Migrated workspace. @param path Legacy write target. @returns Whether any official version owns the file, including decided history. */
7450
+ function hasOfficial(cwd, path) {
7451
+ const db = dbFor(cwd);
7452
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='review_files' AND type='table'").get()) return false;
7453
+ if (/^[A-Za-z]:[\\/]/.test(path)) return !!db.prepare("SELECT 1 FROM review_files WHERE lower(replace(identity,char(92),'/'))=? LIMIT 1").get(path.replace(/\\/g, "/").toLowerCase());
7454
+ return !!db.prepare("SELECT 1 FROM review_files WHERE identity=? LIMIT 1").get(path);
7455
+ }
7456
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param id Immutable file identity. @returns Historical patch, not a reconstruction from disk. */
7457
+ function reviewDetail(cwd, id) {
7458
+ const db = reviewDb(cwd);
7459
+ const row = db.prepare(`SELECT f.*,json_extract(s.payload,'$.capturedAt') AS captured_at FROM review_files f JOIN review_snapshots s
7460
+ USING(session_id,turn,seq) WHERE f.id=?`).get(id);
7461
+ if (!row) return null;
7462
+ const blocks = db.prepare("SELECT block_id AS id,decision FROM review_blocks WHERE file_id=? ORDER BY rowid").all(id);
7463
+ const diff = JSON.parse(String(row.diff));
7464
+ const evidence = db.prepare("SELECT 1 FROM review_evidence WHERE file_id=?").get(id);
7465
+ const pending = row.current === 1 && row.decision === "pending";
7466
+ const undo = pending && !!evidence && diff.kind === "text" && diff.before && diff.after && !diff.coarse;
7467
+ return {
7468
+ item: itemOf(row),
7469
+ diff,
7470
+ capturedAt: String(row.captured_at),
7471
+ blocks,
7472
+ capability: {
7473
+ keep: pending,
7474
+ undo,
7475
+ reason: undo ? "Undo 将校验原始字节、当前文件版本及已处理差异块;文件变化会安全拒绝。" : "该记录缺少可验证的原始字节或受控写回能力;仍可显示差异和 Keep,不能猜测历史全文执行 Undo。"
7476
+ }
7477
+ };
7478
+ }
7479
+ /** @public @author ddj 2026年10月27号 @param db Active decision transaction. @param request File or block action. Updates aggregate without losing mixed decisions. @throws Invalid or already-decided block. */
7480
+ function changeBlocks(db, request) {
7481
+ if (request.scope === "hunk") {
7482
+ if (db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND block_id=? AND decision='pending'").run(request.decision, request.id, request.blockId ?? "").changes !== 1) throw new Error("差异块不存在或已处理");
7483
+ } else db.prepare("UPDATE review_blocks SET decision=? WHERE file_id=? AND decision='pending'").run(request.decision, request.id);
7484
+ const decisions = db.prepare("SELECT DISTINCT decision FROM review_blocks WHERE file_id=?").all(request.id).map((row) => String(row.decision));
7485
+ let aggregate = request.decision;
7486
+ if (decisions.includes("pending")) aggregate = "pending";
7487
+ else if (decisions.length > 1) aggregate = "mixed";
7488
+ else if (decisions.length === 1) aggregate = decisions[0];
7489
+ db.prepare("UPDATE review_files SET decision=? WHERE id=?").run(aggregate, request.id);
7490
+ }
7491
+ /** @public @author ddj 2026年10月26号 @param cwd Workspace. @param request Frozen identity/revision. @returns Idempotent decision; rejection never guesses a target. */
7492
+ function decideReview(cwd, request) {
7493
+ const db = reviewDb(cwd);
7494
+ const encoded = JSON.stringify(request);
7495
+ if (!request.operationId || request.operationId.length > 128) throw new Error("operationId 无效");
7496
+ db.exec("BEGIN IMMEDIATE");
7497
+ try {
7498
+ const previous = db.prepare("SELECT request,result FROM review_ops WHERE operation_id=?").get(request.operationId);
7499
+ if (previous) {
7500
+ if (previous.request !== encoded) throw new Error("operationId 已用于其他请求");
7501
+ if (!previous.result) throw new Error("Undo 尚待写后核对,请重试原操作");
7502
+ db.exec("COMMIT");
7503
+ return JSON.parse(String(previous.result));
7504
+ }
7505
+ if (db.prepare("SELECT 1 FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=?").get(request.id)) throw new Error("该文件有尚待核对的 Undo,暂不能 Keep");
7506
+ if (request.decision !== "accepted") throw new Error("缺少可信全文证据,禁止拒绝写回");
7507
+ if (db.prepare(`UPDATE review_files SET decision='accepted',revision=revision+1
7508
+ WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'`).run(request.id, request.seq, request.revision).changes !== 1) throw new Error("审查版本冲突,请刷新后重试");
7509
+ changeBlocks(db, request);
7510
+ const result = { revision: request.revision + 1 };
7511
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'committed',?,NULL)").run(request.operationId, encoded, JSON.stringify(result));
7512
+ db.exec("COMMIT");
7513
+ return result;
7514
+ } catch (error) {
7515
+ if (db.isTransaction) db.exec("ROLLBACK");
7516
+ throw error;
7517
+ }
7518
+ }
7519
+ //#endregion
7520
+ //#region src/officialCapture.ts
7521
+ /** Deferred official-event collection; no private snapshot access. @author ddj 2026年10月26号 */
7522
+ /** @public @author ddj 2026年10月26号 @param deps Official source and workspace adapters. @returns Bounded collector with cancellation and deferred event reads. */
7523
+ function createCollector(deps) {
7524
+ const waiting = [];
7525
+ const pending = /* @__PURE__ */ new Set();
7526
+ const controller = new AbortController();
7527
+ let active = 0;
7528
+ let stopped = false;
7529
+ const running = /* @__PURE__ */ new Set();
7530
+ const tasks = /* @__PURE__ */ new Set();
7531
+ /** @private @author ddj 2026年10月26号 @param ms Delay; aborted on unload. @returns Deferred completion, not synchronous observer work. */
7532
+ function pause(ms) {
7533
+ return new Promise((resolve) => {
7534
+ /** @private @author ddj 2026年10月26号 Release both timer and listener. */
7535
+ function finish() {
7536
+ clearTimeout(timer);
7537
+ controller.signal.removeEventListener("abort", finish);
7538
+ resolve();
7539
+ }
7540
+ const timer = setTimeout(finish, ms);
7541
+ controller.signal.addEventListener("abort", finish, { once: true });
7542
+ if (stopped) finish();
7543
+ });
7544
+ }
7545
+ /** @private @author ddj 2026年10月26号 @param task Source identity. @returns Complete payload staged before one database transaction. */
7546
+ async function collect(task) {
7547
+ await pause(0);
7548
+ if (stopped) return;
7549
+ const source = deps.source();
7550
+ if (!source) throw new Error("官方 workspaceChanges 服务不可用");
7551
+ let summary;
7552
+ for (let attempt = 0; attempt < 4 && !stopped; attempt++) {
7553
+ summary = source.summary(task.sessionId, task.seq);
7554
+ if (summary) break;
7555
+ await pause(deps.retryMs ?? 100);
7556
+ }
7557
+ if (!summary || stopped) throw new Error("官方事件已公告,但 summary 已失效或尚不可用");
7558
+ summary = structuredClone(summary);
7559
+ if (summary.turn !== task.turn) throw new Error("官方 summary 轮次与事件不一致");
7560
+ task.cwd = summary.cwd;
7561
+ await deps.prepare(summary.cwd);
7562
+ if (stopped) return;
7563
+ deps.state(summary.cwd, {
7564
+ ...task,
7565
+ error: ""
7566
+ }, "queued");
7567
+ try {
7568
+ const files = [];
7569
+ for (const [index, file] of summary.files.entries()) {
7570
+ if (stopped) return;
7571
+ const diff = await readDiff(source, task, index, AbortSignal.any([controller.signal, AbortSignal.timeout(deps.timeoutMs ?? 1e4)]));
7572
+ if (!diff || diff.path !== file.path || diff.display !== file.display) throw new Error("官方文件差异缺失或身份不一致");
7573
+ const identity = await deps.identity(summary.cwd, file.path);
7574
+ files.push({
7575
+ identity,
7576
+ diff: structuredClone(diff)
7577
+ });
7578
+ }
7579
+ if (!stopped) deps.persist(summary.cwd, {
7580
+ ...task,
7581
+ sourceKind: "official",
7582
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
7583
+ summary,
7584
+ files
7585
+ });
7586
+ } catch (error) {
7587
+ if (!stopped) deps.state(summary.cwd, {
7588
+ ...task,
7589
+ error: String(error)
7590
+ }, "gap");
7591
+ throw error;
7592
+ }
7593
+ }
7594
+ /** @private @author ddj 2026年10月26号 Starts at most two independent event reads, with each event's file reads serial. */
7595
+ function drain() {
7596
+ while (!stopped && active < 2 && waiting.length) {
7597
+ const index = waiting.findIndex((task) => !running.has(JSON.stringify([task.sessionId, task.turn])));
7598
+ if (index < 0) break;
7599
+ const task = waiting.splice(index, 1)[0];
7600
+ const key = JSON.stringify([
7601
+ task.sessionId,
7602
+ task.turn,
7603
+ task.seq
7604
+ ]);
7605
+ const turnKey = JSON.stringify([task.sessionId, task.turn]);
7606
+ active++;
7607
+ running.add(turnKey);
7608
+ const work = collect(task).catch(async (error) => {
7609
+ deps.report(`官方差异采集 ${key}: ${String(error)}`);
7610
+ if (!stopped && task.cwd) try {
7611
+ await deps.prepare(task.cwd);
7612
+ if (!stopped) deps.state(task.cwd, {
7613
+ ...task,
7614
+ error: String(error)
7615
+ }, "gap");
7616
+ } catch (failure) {
7617
+ deps.report(`官方缺口落盘失败: ${String(failure)}`);
7618
+ }
7619
+ }).finally(() => {
7620
+ active--;
7621
+ pending.delete(key);
7622
+ running.delete(turnKey);
7623
+ tasks.delete(work);
7624
+ drain();
7625
+ });
7626
+ tasks.add(work);
7627
+ }
7628
+ }
7629
+ /** @public @author ddj 2026年10月26号 @param session Source session. @param event Published event; inherited fork events are excluded. */
7630
+ function enqueue(session, event) {
7631
+ if (stopped || event.type !== "workspace/changes" || !Number.isSafeInteger(event.data?.turn) || !Number.isSafeInteger(event.seq) || event.seq < (session.inheritedEventCount ?? 0)) return;
7632
+ const task = {
7633
+ sessionId: session.id,
7634
+ turn: event.data.turn,
7635
+ seq: event.seq,
7636
+ cwd: session.header?.cwd
7637
+ };
7638
+ const key = JSON.stringify([
7639
+ task.sessionId,
7640
+ task.turn,
7641
+ task.seq
7642
+ ]);
7643
+ if (pending.has(key)) return;
7644
+ pending.add(key);
7645
+ waiting.push(task);
7646
+ drain();
7647
+ }
7648
+ /** @public @author ddj 2026年10月26号 @returns Bounded durability checkpoint including work queued while active tasks finish. */
7649
+ async function flush() {
7650
+ const deadline = Date.now() + (deps.timeoutMs ?? 1e4);
7651
+ while (tasks.size && !stopped && Date.now() < deadline) {
7652
+ let timer;
7653
+ await Promise.race([Promise.all([...tasks]), new Promise((resolve) => {
7654
+ timer = setTimeout(resolve, Math.max(1, deadline - Date.now()));
7655
+ })]);
7656
+ clearTimeout(timer);
7657
+ }
7658
+ }
7659
+ /** @public @author ddj 2026年10月26号 @returns Bounded drain, then cancel timers and reads; incomplete staging cannot publish. */
7660
+ async function dispose() {
7661
+ await flush();
7662
+ stopped = true;
7663
+ controller.abort();
7664
+ waiting.length = 0;
7665
+ pending.clear();
7666
+ }
7667
+ return {
7668
+ enqueue,
7669
+ flush,
7670
+ dispose
7671
+ };
7672
+ }
7673
+ /** @private @author ddj 2026年10月26号 @param source Public service. @param task Event. @param index File position. @param signal Cancellation/deadline. @returns A read bounded even if an adapter ignores abort. */
7674
+ async function readDiff(source, task, index, signal) {
7675
+ return new Promise((resolve, reject) => {
7676
+ /** @private @author ddj 2026年10月26号 Propagate timeout or unload. */
7677
+ function aborted() {
7678
+ reject(signal.reason);
7679
+ }
7680
+ signal.addEventListener("abort", aborted, { once: true });
7681
+ if (signal.aborted) {
7682
+ aborted();
7683
+ return;
7684
+ }
7685
+ Promise.resolve().then(() => source.diff(task.sessionId, task.seq, index, signal)).then(resolve, reject).finally(() => signal.removeEventListener("abort", aborted));
7686
+ });
7687
+ }
7688
+ /** @public @author ddj 2026年10月26号 @param ctx Host lifecycle owner; services remain optional for older Hosts. */
7689
+ function installCapture(ctx, onSnapshot) {
7690
+ const collector = createCollector({
7691
+ source: () => ctx.get("workspaceChanges"),
7692
+ prepare: async (cwd) => {
7693
+ await loadBucket(ctx, cwd);
7694
+ await repairPaths(cwd, resolvePath);
7695
+ },
7696
+ identity: resolvePath,
7697
+ persist: (cwd, snapshot) => {
7698
+ const saved = saveSnapshot(cwd, snapshot);
7699
+ onSnapshot?.(cwd, snapshot);
7700
+ return saved;
7701
+ },
7702
+ state: captureState,
7703
+ report: (message) => log.warn(message)
7704
+ });
7705
+ ctx.on("session/event", collector.enqueue);
7706
+ ctx.on("session/created", backfill);
7707
+ ctx.on("session/flush", collector.flush);
7708
+ for (const session of ctx.get("sessions").list()) backfill(session);
7709
+ return collector.dispose;
7710
+ /** @private @author ddj 2026年10月27号 @param cwd Source workspace. @param path Official relative path. @returns Backend-owned process path; never stringify opaque targets. @throws Invalid backend path. */
7711
+ async function resolvePath(cwd, path) {
7712
+ const fs = ctx.get("fs");
7713
+ const target = await fs.resolve(path, { cwd });
7714
+ const processPath = fs.processPath(target);
7715
+ if (typeof processPath !== "string" || !processPath || processPath === "[object Object]") throw new Error("官方文件路径解析失败");
7716
+ return processPath;
7717
+ }
7718
+ /** @private @author ddj 2026年10月26号 @param session Live session. Only public async reads, excluding inherited records. */
7719
+ async function backfill(session) {
7720
+ const query = ctx.get("sessionQuery");
7721
+ if (!query?.listEvents || !query?.readEvent) {
7722
+ log.warn("官方历史补采不可用:缺少 sessionQuery;后续实时事件仍会采集");
7723
+ return;
7724
+ }
7725
+ try {
7726
+ if (session.header?.cwd) {
7727
+ await loadBucket(ctx, session.header.cwd);
7728
+ await repairPaths(session.header.cwd, resolvePath);
7729
+ }
7730
+ const events = await query.listEvents(session.id);
7731
+ for (const event of events) {
7732
+ if (event.type !== "workspace/changes" || event.seq < (session.inheritedEventCount ?? 0)) continue;
7733
+ const window = await query.readEvent({
7734
+ sessionId: session.id,
7735
+ seq: event.seq
7736
+ });
7737
+ collector.enqueue(session, window.target);
7738
+ }
7739
+ } catch (error) {
7740
+ log.warn("官方历史补采失败:" + String(error));
7741
+ }
7742
+ }
7743
+ }
7744
+ //#endregion
7745
+ //#region src/reviewEvidence.ts
7746
+ /** Raw UTF-8 evidence and patch correspondence; never reconstructs a historical file from disk. @author ddj 2026年10月27号 */
7747
+ /** @public @author ddj 2026年10月27号 @param bytes File bytes, or null for confirmed absence. @returns Existence-sensitive immutable digest and base64. */
7748
+ function rawReviewFile(bytes) {
7749
+ const payload = bytes === null ? Buffer.alloc(0) : Buffer.from(bytes);
7750
+ return {
7751
+ present: bytes !== null,
7752
+ bytes: payload.toString("base64"),
7753
+ hash: createHash("sha256").update(bytes === null ? "absent:" : "present:").update(payload).digest("hex")
7754
+ };
7755
+ }
7756
+ /** @private @author ddj 2026年10月27号 @param file Persisted raw image. @returns UTF-8 storage text preserving BOM and terminators. @throws Binary, invalid encoding or corrupt hash. */
7757
+ function rawText(file) {
7758
+ const bytes = Buffer.from(file.bytes, "base64");
7759
+ if (bytes.length > 8388608 || bytes.includes(0)) throw new Error("回滚证据不是受支持的 UTF-8 文本");
7760
+ if (rawReviewFile(file.present ? bytes : null).hash !== file.hash) throw new Error("回滚证据哈希不匹配");
7761
+ const text = new TextDecoder("utf-8", {
7762
+ fatal: true,
7763
+ ignoreBOM: true
7764
+ }).decode(bytes);
7765
+ if (!Buffer.from(text, "utf8").equals(bytes)) throw new Error("回滚证据无法字节往返");
7766
+ return text;
7767
+ }
7768
+ /** @private @author ddj 2026年10月27号 @param text Storage text. @returns Physical lines, retaining each original line ending and excluding BOM. */
7769
+ function rawLines(text) {
7770
+ return text.replace(/^\uFEFF/, "").match(/[^\r\n]*(?:\r\n|\n|\r|$)/g)?.filter((line, index, lines) => line !== "" || index !== lines.length - 1) ?? [];
7771
+ }
7772
+ /** @private @author ddj 2026年10月27号 @param lines Physical storage lines. @returns Official logical-line comparison basis. */
7773
+ function logical(lines) {
7774
+ return lines.map((line) => line.replace(/(?:\r\n|\r|\n)$/, ""));
7775
+ }
7776
+ /** @public @author ddj 2026年10月27号 @param diff Official source. @param evidence Independent raw captures. @returns Exact raw segments only when every official hunk reproduces the entire captured result. @throws Incomplete chain, coarse patch or mismatched context/content. */
7777
+ function evidenceBlocks(diff, evidence) {
7778
+ if (diff.kind !== "text" || diff.coarse) throw new Error("该官方差异没有可靠文本基线");
7779
+ if (diff.before !== evidence.before.present || diff.after !== evidence.after.present) throw new Error("文件存在性证据不一致");
7780
+ const beforeText = rawText(evidence.before), afterText = rawText(evidence.after);
7781
+ const before = rawLines(beforeText), after = rawLines(afterText);
7782
+ const expected = logical(before);
7783
+ let delta = 0;
7784
+ for (const hunk of diff.hunks) {
7785
+ const old = hunk.lines.filter((line) => line[0] === " " || line[0] === "-").map((line) => line.slice(1));
7786
+ const next = hunk.lines.filter((line) => line[0] === " " || line[0] === "+").map((line) => line.slice(1));
7787
+ if (old.length !== hunk.oldLines || next.length !== hunk.newLines) throw new Error("官方差异行数不一致");
7788
+ const position = Math.max(0, hunk.oldStart - 1) + delta;
7789
+ if (JSON.stringify(expected.slice(position, position + old.length)) !== JSON.stringify(old)) throw new Error("原始基线与官方上下文不一致");
7790
+ expected.splice(position, old.length, ...next);
7791
+ delta += next.length - old.length;
7792
+ }
7793
+ if (JSON.stringify(expected) !== JSON.stringify(logical(after))) throw new Error("采集链未覆盖全部官方变更");
7794
+ if (!diff.hunks.length && beforeText !== afterText) throw new Error("官方没有提供可撤销的变更块");
7795
+ const segments = reviewBlocks(diff).map((block) => ({
7796
+ id: block.id,
7797
+ before: before.slice(block.oldStart - 1, block.oldStart - 1 + block.oldLines.length).join(""),
7798
+ after: after.slice(block.newStart - 1, block.newStart - 1 + block.newLines.length).join(""),
7799
+ offset: (afterText.startsWith("") ? 1 : 0) + after.slice(0, block.newStart - 1).join("").length
7800
+ }));
7801
+ let reversed = afterText;
7802
+ for (const segment of [...segments].reverse()) reversed = reversed.slice(0, segment.offset) + segment.before + reversed.slice(segment.offset + segment.after.length);
7803
+ if (reversed !== beforeText) throw new Error("官方变更外的 BOM、行尾或末尾换行发生变化,证据不足");
7804
+ return segments;
7805
+ }
7806
+ /** @public @author ddj 2026年10月27号 @param evidence Raw original images. @returns Exact before and after UTF-8 storage text for guarded writes. */
7807
+ function evidenceText(evidence) {
7808
+ return {
7809
+ before: rawText(evidence.before),
7810
+ after: rawText(evidence.after)
7811
+ };
7812
+ }
7813
+ //#endregion
7814
+ //#region src/reviewCapture.ts
7815
+ /** Independent byte evidence; official diff remains the only display authority. @author ddj 2026年10月27号 */
7816
+ /** @public @author ddj 2026年10月27号 @param ctx Host lifecycle owner. @returns Scoped byte recorder; failures disable undo evidence only, never tool execution. */
7817
+ function installEvidence(ctx) {
7818
+ const chains = /* @__PURE__ */ new Map();
7819
+ const staged = /* @__PURE__ */ new Map();
7820
+ let stopped = false;
7821
+ ctx.on("tools/execute", aroundTool);
7822
+ ctx.on("session/event", onEvent);
7823
+ ctx.on("session/disposed", onDisposed);
7824
+ /** @private @author ddj 2026年10月27号 @param session Disposed owner. Drops optional in-memory evidence, never persisted official facts. */
7825
+ function onDisposed(session) {
7826
+ chains.delete(session.id);
7827
+ }
7828
+ /** @private @author ddj 2026年10月27号 @returns Current bounded capture footprint, excluding transient filesystem reads. */
7829
+ function footprint() {
7830
+ let bytes = 0;
7831
+ for (const group of [chains, staged]) for (const files of group.values()) for (const chain of files.values()) bytes += chain.before.bytes.length + chain.after.bytes.length;
7832
+ return bytes;
7833
+ }
7834
+ /** @private @author ddj 2026年10月27号 @param exec Allowed tool dispatch. @param next Original dispatch. @returns Original result unchanged, regardless of capture failure. */
7835
+ async function aroundTool(exec, next) {
7836
+ const session = exec.agent?.session;
7837
+ const path = exec.arguments?.file_path;
7838
+ if (stopped || !session?.id || !["edit", "write"].includes(exec.name) || typeof path !== "string") return next();
7839
+ let fs;
7840
+ let chain;
7841
+ let target;
7842
+ try {
7843
+ if (chains.size >= 16 && !chains.has(session.id) || footprint() >= 33554432) return next();
7844
+ fs = ctx.get("fs");
7845
+ target = await resolveTarget(ctx, session, path);
7846
+ const identity = fs.processPath(target);
7847
+ const before = await readRaw(fs, target, exec.signal);
7848
+ let files = chains.get(session.id);
7849
+ if (!files) {
7850
+ files = /* @__PURE__ */ new Map();
7851
+ chains.set(session.id, files);
7852
+ }
7853
+ if (files.size >= 64 && !files.has(identity)) return next();
7854
+ chain = files.get(identity);
7855
+ if (!chain) {
7856
+ chain = {
7857
+ before,
7858
+ after: before,
7859
+ busy: false,
7860
+ invalid: false
7861
+ };
7862
+ files.set(identity, chain);
7863
+ }
7864
+ if (chain.busy || chain.after.hash !== before.hash) chain.invalid = true;
7865
+ chain.busy = true;
7866
+ } catch {}
7867
+ try {
7868
+ return await next();
7869
+ } finally {
7870
+ if (chain) {
7871
+ try {
7872
+ chain.after = await readRaw(fs, target, exec.signal);
7873
+ } catch {
7874
+ chain.invalid = true;
7875
+ }
7876
+ chain.busy = false;
7877
+ }
7878
+ }
7879
+ }
7880
+ /** @private @author ddj 2026年10月27号 @param session Source session. @param event Published official turn. Freezes the chain before the next turn can mutate it. */
7881
+ function onEvent(session, event) {
7882
+ if (stopped || event.type !== "workspace/changes") return;
7883
+ const files = chains.get(session.id);
7884
+ chains.delete(session.id);
7885
+ if (!files || staged.size >= 128) return;
7886
+ staged.set(JSON.stringify([session.id, event.seq]), structuredClone(files));
7887
+ }
7888
+ /** @public @author ddj 2026年10月27号 @param cwd Official workspace. @param snapshot Committed official facts. Persists only independently validated, exact-correspondence byte chains. */
7889
+ function persist(cwd, snapshot) {
7890
+ const key = JSON.stringify([snapshot.sessionId, snapshot.seq]);
7891
+ const files = staged.get(key);
7892
+ staged.delete(key);
7893
+ if (!files || stopped) return;
7894
+ const db = reviewDb(cwd);
7895
+ for (const file of snapshot.files) {
7896
+ const chain = files.get(file.identity);
7897
+ if (!chain || chain.invalid || chain.busy) continue;
7898
+ try {
7899
+ const evidence = {
7900
+ before: chain.before,
7901
+ after: chain.after
7902
+ };
7903
+ evidenceBlocks(file.diff, evidence);
7904
+ db.prepare(`INSERT OR IGNORE INTO review_evidence(file_id,payload)
7905
+ SELECT id,? FROM review_files WHERE session_id=? AND turn=? AND seq=? AND identity=?`).run(JSON.stringify(evidence), snapshot.sessionId, snapshot.turn, snapshot.seq, file.identity);
7906
+ } catch (error) {
7907
+ log.warn("官方回滚证据不可用:" + String(error));
7908
+ }
7909
+ }
7910
+ }
7911
+ /** @public @author ddj 2026年10月27号 Release bounded in-memory images after capture has drained. */
7912
+ function dispose() {
7913
+ stopped = true;
7914
+ chains.clear();
7915
+ staged.clear();
7916
+ }
7917
+ return {
7918
+ persist,
7919
+ dispose
7920
+ };
7921
+ }
7922
+ /** @private @author ddj 2026年10月27号 @param fs Public filesystem. @param target Backend identity. @returns Raw bounded file or confirmed missing. @throws Any non-missing read error. */
7923
+ async function readRaw(fs, target, signal) {
7924
+ try {
7925
+ return rawReviewFile(await fs.readBytes(target, signal, 1048576));
7926
+ } catch (error) {
7927
+ const code = error.code;
7928
+ if (code === "ENOENT" || code === "FS_NOT_FOUND") return rawReviewFile(null);
7929
+ throw error;
7930
+ }
7931
+ }
7932
+ //#endregion
7173
7933
  //#region src/search/query.ts
7174
7934
  /**
7175
7935
  * 规范化用户搜索文本。
@@ -12111,6 +12871,142 @@ function decodePage(bytes, info, offset, pendingCR, args, identity, signCursor)
12111
12871
  };
12112
12872
  }
12113
12873
  //#endregion
12874
+ //#region src/reviewUndo.ts
12875
+ /** Guarded official undo with a durable write-intent journal. @author ddj 2026年10月27号 */
12876
+ const writes = /* @__PURE__ */ new Map();
12877
+ /** @public @author ddj 2026年10月27号 @param ctx Host capability. @param session Authenticated caller. @param cwd Workspace. @param request Frozen file/block version. @returns Durable decision; retries reconcile disk hashes and never blindly repeat a write. @throws Missing evidence, stale state, policy or uncertain write failure. */
12878
+ async function undoReview(ctx, session, cwd, request) {
12879
+ const detail = reviewDetail(cwd, request.id);
12880
+ const key = JSON.stringify([cwd, detail?.item.currentPath ?? request.id]);
12881
+ const operation = (writes.get(key) ?? Promise.resolve()).catch(() => void 0).then(() => runUndo(ctx, session, cwd, request));
12882
+ writes.set(key, operation);
12883
+ try {
12884
+ return await operation;
12885
+ } finally {
12886
+ if (writes.get(key) === operation) writes.delete(key);
12887
+ }
12888
+ }
12889
+ /** @private @author ddj 2026年10月27号 @param ctx Host. @param session Caller. @param cwd Workspace. @param request Frozen decision. @returns Serialized guarded mutation and journal reconciliation. @throws Conflicting bytes or decisions. */
12890
+ async function runUndo(ctx, session, cwd, request) {
12891
+ const db = reviewDb(cwd);
12892
+ const encoded = JSON.stringify(request);
12893
+ if (!request.operationId || request.operationId.length > 128) throw new Error("operationId 无效");
12894
+ const prior = db.prepare("SELECT * FROM review_ops WHERE operation_id=?").get(request.operationId);
12895
+ if (prior && prior.request !== encoded) throw new Error("operationId 已用于其他请求");
12896
+ if (prior?.state === "committed") return JSON.parse(prior.result);
12897
+ const detail = reviewDetail(cwd, request.id);
12898
+ if (!detail || !detail.item.current || detail.item.seq !== request.seq || detail.item.revision !== request.revision || detail.item.decision !== "pending") throw new Error("审查版本冲突,请刷新后重试");
12899
+ const fs = ctx.get("fs");
12900
+ if (!fs?.readBytes || !fs?.writeText || !fs?.stat) throw new Error("后端缺少受控回滚能力");
12901
+ const target = await resolveTarget(ctx, session, detail.item.currentPath);
12902
+ const info = await fs.stat(target);
12903
+ if (!info || info.type !== "file" || !info.version) throw new Error("文件不存在或缺少版本守卫,不能安全 Undo");
12904
+ const current = rawReviewFile(await fs.readBytes(target, void 0, 8388608));
12905
+ if ((await fs.stat(target))?.version !== info.version) throw new Error("文件在回滚读取期间变化,未修改文件");
12906
+ if (prior) {
12907
+ const journal = JSON.parse(prior.evidence);
12908
+ if (current.hash === journal.after) return finishUndo(cwd, request);
12909
+ if (current.hash !== journal.before) throw new Error("上次 Undo 状态不确定:磁盘既非写前也非写后,禁止重复执行");
12910
+ }
12911
+ if (db.prepare("SELECT operation_id FROM review_ops WHERE state='prepared' AND json_extract(request,'$.id')=? AND operation_id<>?").get(request.id, request.operationId)) throw new Error("存在未核对的 Undo,请使用原操作重试,不能创建另一项写入");
12912
+ const row = db.prepare("SELECT payload FROM review_evidence WHERE file_id=?").get(request.id);
12913
+ if (!row) throw new Error("该历史记录缺少可信原始字节证据,不能 Undo;差异仍可展示和 Keep");
12914
+ const evidence = JSON.parse(row.payload);
12915
+ const segments = evidenceBlocks(detail.diff, evidence);
12916
+ const texts = evidenceText(evidence);
12917
+ if (!evidence.before.present || !evidence.after.present) throw new Error("当前后端未提供受控原子删除/重建能力");
12918
+ const selected = detail.blocks.filter((block) => block.decision === "pending" && (request.scope !== "hunk" || block.id === request.blockId));
12919
+ if (!selected.length) throw new Error("差异块不存在或已处理");
12920
+ const rejected = new Set(detail.blocks.filter((block) => block.decision === "rejected").map((block) => block.id));
12921
+ const expected = reverseBlocks(texts.after, segments, rejected);
12922
+ if (rawReviewFile(Buffer.from(expected)).hash !== current.hash) throw new Error("当前文件已被后续编辑,版本与回滚证据冲突,未修改文件");
12923
+ const undo = /* @__PURE__ */ new Set([...rejected, ...selected.map((block) => block.id)]);
12924
+ const next = reverseBlocks(texts.after, segments, undo);
12925
+ const journal = {
12926
+ before: current.hash,
12927
+ after: rawReviewFile(Buffer.from(next)).hash
12928
+ };
12929
+ const latest = reviewDetail(cwd, request.id);
12930
+ if (!latest?.item.current || latest.item.revision !== request.revision || latest.item.decision !== "pending") throw new Error("审查版本在回滚校验期间变化,未修改文件");
12931
+ db.prepare("INSERT INTO review_ops VALUES(?,?,'prepared',NULL,?) ON CONFLICT(operation_id) DO NOTHING").run(request.operationId, encoded, JSON.stringify(journal));
12932
+ await fs.writeText(target, next, {
12933
+ kind: "replaceIfVersion",
12934
+ version: info.version
12935
+ }, void 0, policyOf(ctx, session));
12936
+ if (rawReviewFile(await fs.readBytes(target, void 0, 8388608)).hash !== journal.after) throw new Error("Undo 写后内容不确定,请保留原操作重试核对");
12937
+ return finishUndo(cwd, request);
12938
+ }
12939
+ /** @private @author ddj 2026年10月27号 @param text Captured result. @param segments Validated raw ranges. @param rejected Blocks to reverse. @returns Raw storage text preserving unselected blocks. */
12940
+ function reverseBlocks(text, segments, rejected) {
12941
+ let result = text;
12942
+ for (const segment of [...segments].reverse()) {
12943
+ if (!rejected.has(segment.id)) continue;
12944
+ result = result.slice(0, segment.offset) + segment.before + result.slice(segment.offset + segment.after.length);
12945
+ }
12946
+ return result;
12947
+ }
12948
+ /** @private @author ddj 2026年10月27号 @param cwd Workspace. @param request Frozen action. @returns Transactionally committed status, or a retryable prepared journal if the database fails. */
12949
+ function finishUndo(cwd, request) {
12950
+ const db = reviewDb(cwd);
12951
+ db.exec("BEGIN IMMEDIATE");
12952
+ try {
12953
+ if (db.prepare("UPDATE review_files SET revision=revision+1 WHERE id=? AND seq=? AND revision=? AND current=1 AND decision='pending'").run(request.id, request.seq, request.revision).changes !== 1) throw new Error("审查版本冲突,请核对已执行 Undo");
12954
+ changeBlocks(db, request);
12955
+ const result = { revision: request.revision + 1 };
12956
+ db.prepare("UPDATE review_ops SET state='committed',result=? WHERE operation_id=?").run(JSON.stringify(result), request.operationId);
12957
+ db.exec("COMMIT");
12958
+ return result;
12959
+ } catch (error) {
12960
+ if (db.isTransaction) db.exec("ROLLBACK");
12961
+ throw error;
12962
+ }
12963
+ }
12964
+ //#endregion
12965
+ //#region src/reviewRpc.ts
12966
+ /** @public @author ddj 2026年10月26号 @param ctx Host. @param registry Workspace cache. @returns Handlers resolving workspace from the live authenticated session, never caller cwd. */
12967
+ function reviewHandlers(ctx, registry) {
12968
+ /** @private @author ddj 2026年10月26号 @param sessionId Request scope. @returns Migrated workspace, or explicit failure without fallback. */
12969
+ async function workspace(sessionId) {
12970
+ const session = sessionOf(ctx, sessionId);
12971
+ const cwd = cwdOfSession(ctx, session);
12972
+ if (!session || !cwd) throw new Error("会话不存在或没有工作区");
12973
+ await bucketOf(registry, ctx, cwd);
12974
+ return cwd;
12975
+ }
12976
+ return {
12977
+ "edrv.review.list": async (args) => {
12978
+ return {
12979
+ ok: true,
12980
+ page: reviewPage(await workspace(args.sessionId), args.cursor, args.history, args.path)
12981
+ };
12982
+ },
12983
+ "edrv.review.detail": async (args) => {
12984
+ const detail = reviewDetail(await workspace(args.sessionId), args.id);
12985
+ return detail ? {
12986
+ ok: true,
12987
+ detail
12988
+ } : {
12989
+ ok: false,
12990
+ error: "官方审查记录不存在"
12991
+ };
12992
+ },
12993
+ "edrv.review.decide": async (args) => {
12994
+ const cwd = await workspace(args.sessionId);
12995
+ try {
12996
+ return {
12997
+ ok: true,
12998
+ ...args.decision === "rejected" ? await undoReview(ctx, sessionOf(ctx, args.sessionId), cwd, args) : decideReview(cwd, args)
12999
+ };
13000
+ } catch (error) {
13001
+ return {
13002
+ ok: false,
13003
+ error: error instanceof Error ? error.message : String(error)
13004
+ };
13005
+ }
13006
+ }
13007
+ };
13008
+ }
13009
+ //#endregion
12114
13010
  //#region src/rpc.ts
12115
13011
  /** Process-local cursor signer; reads must stay on the Host filesystem capability. */
12116
13012
  const readTextPage = createTextPages();
@@ -12264,22 +13160,6 @@ function isStaleVersion(error) {
12264
13160
  */
12265
13161
  async function afterManualSave(ctx, registry, sc, path) {
12266
13162
  invalidateIndex(ctx, sc.cwd, path);
12267
- const bucket = await bucketOf(registry, ctx, sc.cwd);
12268
- let changed = false;
12269
- for (const rec of bucket.values()) if (rec.path === path && rec.superseded !== true) {
12270
- rec.superseded = true;
12271
- rec.at = (/* @__PURE__ */ new Date()).toISOString();
12272
- changed = true;
12273
- }
12274
- if (changed) {
12275
- const done = [];
12276
- for (const rec of bucket.values()) if (rec.path === path && rec.superseded) done.push(rec);
12277
- if (done.length) await archiveRecords(ctx, sc.session, sc.cwd, bucket, done, "被手动编辑覆盖");
12278
- else await saveBucket(ctx, sc.cwd, bucket, sc.session, {
12279
- upserts: [...bucket.values()].filter((rec) => rec.path === path),
12280
- removed: []
12281
- });
12282
- }
12283
13163
  }
12284
13164
  /**
12285
13165
  * 批量决策核心:一次会话/桶解析 + 逐项处理 + 统一落盘(accept/reject 单条与 decideBatch 共用)。
@@ -12313,6 +13193,14 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
12313
13193
  });
12314
13194
  continue;
12315
13195
  }
13196
+ if (hasOfficial(cwd, record.path)) {
13197
+ results.push({
13198
+ callId: item.callId,
13199
+ ok: false,
13200
+ error: "该文件已有官方审查记录;旧工具记录禁止决策或回滚,请使用官方审查入口"
13201
+ });
13202
+ continue;
13203
+ }
12316
13204
  if (record.archived) {
12317
13205
  results.push({
12318
13206
  callId: item.callId,
@@ -12333,7 +13221,6 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
12333
13221
  });
12334
13222
  continue;
12335
13223
  }
12336
- let revertedStale = false;
12337
13224
  if (item.decision === "rejected" && !alreadyDone) {
12338
13225
  const scope = item.scope ?? "call";
12339
13226
  const hunkIndex = scope === "hunk" ? item.hunkIndex ?? -1 : -1;
@@ -12397,8 +13284,8 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
12397
13284
  if (traceId && !grouped.has(index)) revertMs += performance.now() - revertStart;
12398
13285
  grouped.delete(index);
12399
13286
  try {
12400
- if (outcome.ok || outcome.stale === true) setRevertState(cwd, record.callId, scope, hunkIndex, "applied");
12401
- else if (outcome.error === "找不到该差异块" || outcome.error === "回滚失败:文件不存在" || outcome.error === "回滚失败:文件过大,无法安全定位") clearRevertState(cwd, record.callId, scope, hunkIndex);
13287
+ if (outcome.ok) setRevertState(cwd, record.callId, scope, hunkIndex, "applied");
13288
+ else if (outcome.stale === true || outcome.error === "找不到该差异块" || outcome.error === "回滚失败:文件不存在" || outcome.error === "回滚失败:文件过大,无法安全定位") clearRevertState(cwd, record.callId, scope, hunkIndex);
12402
13289
  } catch (error) {
12403
13290
  results.push({
12404
13291
  callId: item.callId,
@@ -12408,15 +13295,12 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
12408
13295
  continue;
12409
13296
  }
12410
13297
  if (!outcome.ok) {
12411
- if (scope !== "hunk" || outcome.stale !== true) {
12412
- results.push({
12413
- callId: item.callId,
12414
- ok: false,
12415
- error: outcome.error
12416
- });
12417
- continue;
12418
- }
12419
- revertedStale = true;
13298
+ results.push({
13299
+ callId: item.callId,
13300
+ ok: false,
13301
+ error: outcome.error || "差异已偏移,未修改文件,保留待处理"
13302
+ });
13303
+ continue;
12420
13304
  }
12421
13305
  }
12422
13306
  const wasResolved = recordResolved(record);
@@ -12429,7 +13313,6 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
12429
13313
  ok: true,
12430
13314
  record: recView(record, ws)
12431
13315
  };
12432
- if (revertedStale) result.stale = true;
12433
13316
  results.push(result);
12434
13317
  }
12435
13318
  const decisionMs = traceId ? performance.now() - started : 0;
@@ -12616,6 +13499,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
12616
13499
  ...aiHandlers ?? {},
12617
13500
  ...svnHandlers ?? {},
12618
13501
  ...dapHandlers ?? {},
13502
+ ...reviewHandlers(ctx, registry),
12619
13503
  "edrv.list": async (args) => {
12620
13504
  const start = performance.now();
12621
13505
  let staleMs = 0;
@@ -13195,6 +14079,10 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
13195
14079
  };
13196
14080
  const batch = args.batch;
13197
14081
  const bucket = await bucketOf(registry, ctx, sc.cwd);
14082
+ if (hasOfficial(sc.cwd, args.path)) return {
14083
+ ok: false,
14084
+ error: "该文件已有官方审查记录,禁止使用旧版批次回滚覆盖当前文件"
14085
+ };
13198
14086
  try {
13199
14087
  let affected = [];
13200
14088
  let beforeRec = null;
@@ -24758,6 +25646,11 @@ function createRuntime(ctx, config) {
24758
25646
  * @param runtime Managers created by this instance.
24759
25647
  */
24760
25648
  function attachRuntime(ctx, runtime) {
25649
+ const evidence = installEvidence(ctx);
25650
+ const stopCapture = installCapture(ctx, (cwd, snapshot) => {
25651
+ evidence.persist(cwd, snapshot);
25652
+ return true;
25653
+ });
24761
25654
  /** @author ddj 2026年09月28号 @param exec Tool execution. @param result Tool result. */
24762
25655
  function onResult(exec, result) {
24763
25656
  captureToolResult(ctx, runtime.registry, exec, result);
@@ -24775,6 +25668,8 @@ function attachRuntime(ctx, runtime) {
24775
25668
  }
24776
25669
  /** @author ddj 2026年09月28号 Release every manager even if another cleanup rejects. */
24777
25670
  async function cleanup() {
25671
+ await stopCapture();
25672
+ evidence.dispose();
24778
25673
  runtime.fileVersions.dispose();
24779
25674
  closeArchiveDbs();
24780
25675
  disposeAllServers();